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:
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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']));
|
||||
|
||||
@@ -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);
|
||||
|
||||
?>
|
||||
<div class="app-details-container"
|
||||
data-customer-no="<?php e($customerNo); ?>"
|
||||
data-customer-name="<?php e($customerName); ?>"
|
||||
data-support-dashboard-url="<?php e(lurl('helpdesk/debitor-support-dashboard-data')); ?>"
|
||||
data-contacts-url="<?php e(lurl('helpdesk/debitor-contacts-data')); ?>"
|
||||
data-tickets-url="<?php e(lurl('helpdesk/debitor-tickets-data')); ?>"
|
||||
data-ticket-base-url="<?php e(lurl('helpdesk/ticket/')); ?>"
|
||||
data-summary-url="<?php e(lurl('helpdesk/debitor-summary-data')); ?>"
|
||||
data-communication-url="<?php e(lurl('helpdesk/debitor-communication-data')); ?>"
|
||||
data-sales-dashboard-url="<?php e(lurl('helpdesk/debitor-sales-dashboard-data')); ?>"
|
||||
data-controlling-dashboard-url="<?php e(lurl('helpdesk/debitor-controlling-dashboard-data')); ?>"
|
||||
>
|
||||
<section>
|
||||
<?php
|
||||
$breadcrumbs = [
|
||||
['label' => t('Home'), 'path' => 'admin'],
|
||||
['label' => t('Helpdesk'), 'path' => 'helpdesk/debitor'],
|
||||
['label' => t('Helpdesk'), 'path' => 'helpdesk'],
|
||||
['label' => $customerName],
|
||||
];
|
||||
require templatePath('partials/app-breadcrumb.phtml');
|
||||
?>
|
||||
|
||||
<?php
|
||||
$titlebarActions = [];
|
||||
if (!$hasError && $customer !== []) {
|
||||
$titlebarActions[] = [
|
||||
'label' => 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 :
|
||||
</div>
|
||||
<?php elseif ($customer !== []): ?>
|
||||
|
||||
<!-- KPI tiles — values updated by JS after async load -->
|
||||
<div class="app-tiles">
|
||||
<span data-kpi="open-tickets">
|
||||
<?php
|
||||
MintyPHP\Support\Tile::render([
|
||||
'href' => '#',
|
||||
'label' => t('Open tickets'),
|
||||
'count' => '—',
|
||||
'icon' => 'bi bi-ticket-detailed-fill',
|
||||
'iconBg' => '#f3e8ff',
|
||||
'iconColor' => '#7c3aed',
|
||||
]);
|
||||
?>
|
||||
</span>
|
||||
<span data-kpi="last-activity">
|
||||
<?php
|
||||
MintyPHP\Support\Tile::render([
|
||||
'href' => '#',
|
||||
'label' => t('Last activity'),
|
||||
'count' => '—',
|
||||
'icon' => 'bi bi-clock-history',
|
||||
'iconBg' => '#fde8d8',
|
||||
'iconColor' => '#b35c14',
|
||||
]);
|
||||
?>
|
||||
</span>
|
||||
<span data-kpi="contacts">
|
||||
<?php
|
||||
MintyPHP\Support\Tile::render([
|
||||
'href' => '#',
|
||||
'label' => t('Contacts'),
|
||||
'count' => '—',
|
||||
'icon' => 'bi bi-people-fill',
|
||||
'iconBg' => '#d9f2e6',
|
||||
'iconColor' => '#1f6a3a',
|
||||
]);
|
||||
?>
|
||||
</span>
|
||||
<?php
|
||||
MintyPHP\Support\Tile::render([
|
||||
'href' => '#',
|
||||
'label' => t('Support type'),
|
||||
'count' => $supportType !== '' ? $supportType : '—',
|
||||
'icon' => 'bi bi-headset',
|
||||
'iconBg' => '#d9e8fb',
|
||||
'iconColor' => '#1a56db',
|
||||
]);
|
||||
?>
|
||||
</div>
|
||||
|
||||
<!-- Tabs: Overview / Tickets / Contacts -->
|
||||
<!-- Tabs: Support / Sales / Controlling -->
|
||||
<div class="app-tabs" data-tabs data-app-component="tabs" data-tabs-param="tab" data-tabs-storage-key="helpdesk-debitor-detail">
|
||||
<div class="app-tabs-nav">
|
||||
<button type="button" data-tab="overview" data-tab-default>
|
||||
<?php e(t('Overview')); ?>
|
||||
<button type="button" data-tab="support" data-tab-default>
|
||||
<?php e(t('Support')); ?>
|
||||
</button>
|
||||
<button type="button" data-tab="tickets">
|
||||
<?php e(t('Tickets')); ?> <span class="badge" id="tab-badge-tickets"></span>
|
||||
<button type="button" data-tab="sales">
|
||||
<?php e(t('Sales')); ?>
|
||||
</button>
|
||||
<button type="button" data-tab="contacts">
|
||||
<?php e(t('Contacts')); ?> <span class="badge" id="tab-badge-contacts"></span>
|
||||
<button type="button" data-tab="controlling">
|
||||
<?php e(t('Controlling')); ?>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Overview panel -->
|
||||
<div data-tab-panel="overview">
|
||||
<div id="overview-loading" aria-busy="true"><?php e(t('Loading...')); ?></div>
|
||||
<div id="overview-content" hidden>
|
||||
<div class="grid grid-2">
|
||||
<!-- Open tickets (max 10) -->
|
||||
<div class="app-stats-table">
|
||||
<div class="app-stats-table-header">
|
||||
<?php e(t('Open tickets')); ?>
|
||||
<a href="#" class="helpdesk-tab-link" data-switch-tab="tickets"><?php e(t('All tickets')); ?> →</a>
|
||||
</div>
|
||||
<div id="overview-open-tickets"></div>
|
||||
<!-- Support panel -->
|
||||
<div data-tab-panel="support">
|
||||
<div id="support-loading" aria-busy="true"><?php e(t('Loading...')); ?></div>
|
||||
<div id="support-content" hidden>
|
||||
<div class="helpdesk-support-metrics">
|
||||
<article class="helpdesk-support-metric helpdesk-kpi-clickable" data-kpi-filter="open">
|
||||
<p class="helpdesk-support-metric-label"><?php e(t('Open tickets')); ?></p>
|
||||
<p class="helpdesk-support-metric-value" id="support-kpi-open-tickets">—</p>
|
||||
<p class="helpdesk-support-metric-trend" id="support-kpi-open-trend"></p>
|
||||
</article>
|
||||
<article class="helpdesk-support-metric helpdesk-kpi-clickable" data-kpi-filter="">
|
||||
<p class="helpdesk-support-metric-label"><?php e(t('New (30d)')); ?></p>
|
||||
<p class="helpdesk-support-metric-value" id="support-kpi-created-30d">—</p>
|
||||
<p class="helpdesk-support-metric-trend" id="support-kpi-created-trend"></p>
|
||||
</article>
|
||||
<article class="helpdesk-support-metric helpdesk-kpi-clickable" data-kpi-filter="closed">
|
||||
<p class="helpdesk-support-metric-label"><?php e(t('Resolved (30d)')); ?></p>
|
||||
<p class="helpdesk-support-metric-value" id="support-kpi-closed-30d">—</p>
|
||||
<p class="helpdesk-support-metric-trend" id="support-kpi-closed-trend"></p>
|
||||
</article>
|
||||
<article class="helpdesk-support-metric">
|
||||
<p class="helpdesk-support-metric-label"><?php e(t('Avg. age open')); ?></p>
|
||||
<p class="helpdesk-support-metric-value" id="support-kpi-avg-age">—</p>
|
||||
<p class="helpdesk-support-metric-trend" id="support-kpi-age-hint"></p>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<h3 class="helpdesk-support-section-title"><?php e(t('Analysis')); ?></h3>
|
||||
<div class="grid grid-2" id="support-analysis-grid">
|
||||
<div class="app-stats-table" id="support-recommendations-wrapper">
|
||||
<div class="app-stats-table-header"><?php e(t('System recommendations')); ?></div>
|
||||
<div id="support-system-recommendations"></div>
|
||||
</div>
|
||||
|
||||
<!-- Recent activity (5 newest by Last_Activity_Date) -->
|
||||
<div class="app-stats-table">
|
||||
<div class="app-stats-table-header"><?php e(t('Recent activity')); ?></div>
|
||||
<div id="overview-recent-activity"></div>
|
||||
<div class="app-stats-table" id="support-contracts-wrapper">
|
||||
<div class="app-stats-table-header"><?php e(t('Contracts & products')); ?></div>
|
||||
<div id="support-contracts-widget"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-2">
|
||||
<!-- Top contacts (5) -->
|
||||
<div class="app-stats-table">
|
||||
<div class="app-stats-table-header">
|
||||
<?php e(t('Contacts')); ?>
|
||||
<a href="#" class="helpdesk-tab-link" data-switch-tab="contacts"><?php e(t('All contacts')); ?> →</a>
|
||||
</div>
|
||||
<div id="overview-contacts"></div>
|
||||
</div>
|
||||
|
||||
<!-- Support info & master data -->
|
||||
<div class="app-stats-table">
|
||||
<div class="app-stats-table-header"><?php e(t('Support info & master data')); ?></div>
|
||||
<table>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td><strong><?php e(t('Debtor No.')); ?></strong></td>
|
||||
<td><?php e((string) ($customer['No'] ?? '')); ?></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong><?php e(t('Name')); ?></strong></td>
|
||||
<td><?php e((string) ($customer['Name'] ?? '')); ?></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong><?php e(t('Support type')); ?></strong></td>
|
||||
<td><?php e($supportType !== '' ? $supportType : '—'); ?></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong><?php e(t('Salesperson')); ?></strong></td>
|
||||
<td><?php e($salesperson !== '' ? $salesperson : '—'); ?></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong><?php e(t('Location')); ?></strong></td>
|
||||
<td><?php e($locationDisplay !== '' ? $locationDisplay : '—'); ?></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong><?php e(t('Phone')); ?></strong></td>
|
||||
<td><?php e((string) ($customer['Phone_No'] ?? '')); ?></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong><?php e(t('E-Mail')); ?></strong></td>
|
||||
<td><?php e((string) ($customer['E_Mail'] ?? '')); ?></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="app-stats-table" id="support-escalation-wrapper">
|
||||
<div class="app-stats-table-header"><?php e(t('Escalated tickets')); ?></div>
|
||||
<div id="support-escalation-widget"></div>
|
||||
</div>
|
||||
|
||||
<section class="helpdesk-support-ticket-list">
|
||||
<h3 class="helpdesk-support-section-title"><?php e(t('Ticket list')); ?></h3>
|
||||
<?php
|
||||
$filterUiNamespace = 'helpdesk-tickets';
|
||||
require templatePath('partials/app-list-filters.phtml');
|
||||
?>
|
||||
<div id="helpdesk-tickets-grid"></div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tickets panel (Grid.js) -->
|
||||
<div data-tab-panel="tickets" hidden>
|
||||
<?php
|
||||
$filterUiNamespace = 'helpdesk-tickets';
|
||||
require templatePath('partials/app-list-filters.phtml');
|
||||
?>
|
||||
<div id="helpdesk-tickets-grid"></div>
|
||||
<!-- Sales panel -->
|
||||
<div data-tab-panel="sales" hidden>
|
||||
<div id="sales-loading" aria-busy="true"><?php e(t('Loading...')); ?></div>
|
||||
<div id="sales-content" hidden>
|
||||
<div class="helpdesk-support-metrics">
|
||||
<article class="helpdesk-support-metric">
|
||||
<p class="helpdesk-support-metric-label"><?php e(t('Monthly volume')); ?></p>
|
||||
<p class="helpdesk-support-metric-value" id="sales-kpi-monthly-volume">0</p>
|
||||
<p class="helpdesk-support-metric-trend" id="sales-kpi-monthly-volume-trend"></p>
|
||||
</article>
|
||||
<article class="helpdesk-support-metric">
|
||||
<p class="helpdesk-support-metric-label"><?php e(t('Active contracts')); ?></p>
|
||||
<p class="helpdesk-support-metric-value" id="sales-kpi-active-contracts">0</p>
|
||||
<p class="helpdesk-support-metric-trend" id="sales-kpi-active-contracts-trend"></p>
|
||||
</article>
|
||||
<article class="helpdesk-support-metric">
|
||||
<p class="helpdesk-support-metric-label"><?php e(t('Avg. contract value')); ?></p>
|
||||
<p class="helpdesk-support-metric-value" id="sales-kpi-avg-value">0</p>
|
||||
<p class="helpdesk-support-metric-trend" id="sales-kpi-avg-value-trend"></p>
|
||||
</article>
|
||||
<article class="helpdesk-support-metric">
|
||||
<p class="helpdesk-support-metric-label"><?php e(t('Next invoicing')); ?></p>
|
||||
<p class="helpdesk-support-metric-value" id="sales-kpi-next-invoicing">—</p>
|
||||
<p class="helpdesk-support-metric-trend" id="sales-kpi-next-invoicing-trend"></p>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<div id="sales-contracts-detail"></div>
|
||||
|
||||
<section id="sales-contacts-section">
|
||||
<h3 class="helpdesk-support-section-title"><?php e(t('Contacts')); ?></h3>
|
||||
<?php
|
||||
$filterUiNamespace = 'helpdesk-contacts';
|
||||
$searchToolbarFilterSchema = $contactsSearchToolbarFilterSchema;
|
||||
$drawerToolbarFilterSchema = $contactsDrawerToolbarFilterSchema;
|
||||
$toolbarFilterState = $contactsToolbarFilterState;
|
||||
$toolbarOptionSets = $contactsToolbarOptionSets;
|
||||
require templatePath('partials/app-list-filters.phtml');
|
||||
?>
|
||||
<div id="helpdesk-contacts-grid"></div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Contacts panel -->
|
||||
<div data-tab-panel="contacts" hidden>
|
||||
<div id="contacts-loading" aria-busy="true"><?php e(t('Loading...')); ?></div>
|
||||
<div id="contacts-content" hidden></div>
|
||||
<!-- Controlling panel -->
|
||||
<div data-tab-panel="controlling" hidden>
|
||||
<div id="controlling-spinner" class="helpdesk-tab-spinner">
|
||||
<div class="spinner"></div>
|
||||
</div>
|
||||
<div id="controlling-content" hidden>
|
||||
<div class="helpdesk-segment-control" id="controlling-period-selector">
|
||||
<input type="radio" name="controlling-period" id="controlling-period-30" value="30">
|
||||
<label for="controlling-period-30"><?php e(t('30 days')); ?></label>
|
||||
<input type="radio" name="controlling-period" id="controlling-period-90" value="90" checked>
|
||||
<label for="controlling-period-90"><?php e(t('90 days')); ?></label>
|
||||
<input type="radio" name="controlling-period" id="controlling-period-180" value="180">
|
||||
<label for="controlling-period-180"><?php e(t('6 months')); ?></label>
|
||||
<input type="radio" name="controlling-period" id="controlling-period-365" value="365">
|
||||
<label for="controlling-period-365"><?php e(t('1 year')); ?></label>
|
||||
</div>
|
||||
|
||||
<div class="helpdesk-support-metrics" id="controlling-kpis">
|
||||
<div class="helpdesk-support-metric">
|
||||
<div class="helpdesk-support-metric-label"><?php e(t('Ticket volume')); ?></div>
|
||||
<div class="helpdesk-support-metric-value" id="controlling-kpi-volume">—</div>
|
||||
<p class="helpdesk-support-metric-trend" id="controlling-kpi-volume-trend"></p>
|
||||
</div>
|
||||
<div class="helpdesk-support-metric">
|
||||
<div class="helpdesk-support-metric-label"><?php e(t('Close rate')); ?> <span class="helpdesk-kpi-info" data-tooltip="<?php e(t('kpi_tooltip_close_rate')); ?>" data-tooltip-pos="top" tabindex="0">i</span></div>
|
||||
<div class="helpdesk-support-metric-value" id="controlling-kpi-closerate">—</div>
|
||||
<p class="helpdesk-support-metric-trend" id="controlling-kpi-closerate-trend"></p>
|
||||
</div>
|
||||
<div class="helpdesk-support-metric">
|
||||
<div class="helpdesk-support-metric-label"><?php e(t('Avg. resolution')); ?> <span class="helpdesk-kpi-info" data-tooltip="<?php e(t('kpi_tooltip_resolution')); ?>" data-tooltip-pos="top" tabindex="0">i</span></div>
|
||||
<div class="helpdesk-support-metric-value" id="controlling-kpi-resolution">—</div>
|
||||
<p class="helpdesk-support-metric-trend" id="controlling-kpi-resolution-trend"></p>
|
||||
</div>
|
||||
<div class="helpdesk-support-metric">
|
||||
<div class="helpdesk-support-metric-label"><?php e(t('Open backlog')); ?> <span class="helpdesk-kpi-info" data-tooltip="<?php e(t('kpi_tooltip_backlog')); ?>" data-tooltip-pos="top" tabindex="0">i</span></div>
|
||||
<div class="helpdesk-support-metric-value" id="controlling-kpi-backlog">—</div>
|
||||
<p class="helpdesk-support-metric-trend" id="controlling-kpi-backlog-trend"></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="app-stats-table">
|
||||
<div class="app-stats-table-header"><?php e(t('Ticket trend')); ?></div>
|
||||
<div class="helpdesk-controlling-chart" id="controlling-trend-chart"></div>
|
||||
</div>
|
||||
|
||||
<div class="app-stats-table">
|
||||
<div class="app-stats-table-header"><?php e(t('Support efficiency')); ?></div>
|
||||
<div id="controlling-efficiency"></div>
|
||||
</div>
|
||||
|
||||
<div class="app-stats-table">
|
||||
<div class="app-stats-table-header"><?php e(t('Risk indicators')); ?></div>
|
||||
<div id="controlling-risk-indicators"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="controlling-error" hidden></div>
|
||||
<div id="controlling-empty" hidden></div>
|
||||
</div>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
@@ -264,11 +293,32 @@ $locationDisplay = $postCode !== '' && $city !== '' ? $postCode . ' ' . $city :
|
||||
'filterChipMeta' => $filterChipMeta,
|
||||
]); ?></script>
|
||||
|
||||
<!-- Grid.js contacts config -->
|
||||
<script type="application/json" id="page-config-helpdesk-contacts"><?php gridJsonForJs([
|
||||
'dataUrl' => lurl('helpdesk/debitor-contacts-data'),
|
||||
'customerNo' => $customerNo,
|
||||
'customerName' => $customerName,
|
||||
'gridLang' => gridLang(),
|
||||
'labels' => [
|
||||
'name' => t('Name'),
|
||||
'type' => t('Type'),
|
||||
'jobTitle' => t('Job title'),
|
||||
'email' => t('E-Mail'),
|
||||
'phone' => t('Phone'),
|
||||
'mobile' => t('Mobile'),
|
||||
],
|
||||
'gridSearch' => $contactsSearchConfig,
|
||||
'filterSchema' => $contactsClientFilterSchema,
|
||||
'filterChipMeta' => $contactsFilterChipMeta,
|
||||
]); ?></script>
|
||||
|
||||
<!-- i18n strings for JS rendering -->
|
||||
<script type="application/json" id="helpdesk-i18n"><?php gridJsonForJs([
|
||||
'Ticket No.' => t('Ticket No.'),
|
||||
'Description' => t('Description'),
|
||||
'Status' => t('Status'),
|
||||
'Sales' => t('Sales'),
|
||||
'Controlling' => t('Controlling'),
|
||||
'Category' => t('Category'),
|
||||
'Contact' => t('Contact'),
|
||||
'Support' => t('Support'),
|
||||
@@ -286,7 +336,71 @@ $locationDisplay = $postCode !== '' && $city !== '' ? $postCode . ' ' . $city :
|
||||
'No tickets are linked to this debtor in Business Central.' => t('No tickets are linked to this debtor in Business Central.'),
|
||||
'No contacts found' => t('No contacts found'),
|
||||
'No contacts are linked to this debtor in Business Central.' => t('No contacts are linked to this debtor in Business Central.'),
|
||||
'No recent activity found.' => t('No recent activity found.'),
|
||||
'Total tickets' => t('Total tickets'),
|
||||
'Critical (>48h)' => t('Critical (>48h)'),
|
||||
'Oldest open' => t('Oldest open'),
|
||||
'Ticket list' => t('Ticket list'),
|
||||
'System recommendations' => t('System recommendations'),
|
||||
'Action' => t('Action'),
|
||||
'Age (h)' => t('Age (h)'),
|
||||
'No system recommendations right now.' => t('No system recommendations right now.'),
|
||||
'helpdesk.recommendation.escalation_overdue' => t('Escalation SLA exceeded by {overdue_hours}h ({escalation_code}).'),
|
||||
'helpdesk.recommendation.high_risk_aging' => t('High-risk ticket ({escalation_code}) is open for {age_hours}h.'),
|
||||
'helpdesk.recommendation.unassigned_open' => t('Ticket is unassigned for {age_hours}h.'),
|
||||
'helpdesk.recommendation.stale_open' => t('No activity for {age_hours}h.'),
|
||||
'helpdesk.recommendation.customer_backlog' => t('Customer backlog: {open_tickets} open tickets.'),
|
||||
'Contracts & products' => t('Contracts & products'),
|
||||
'No contracts found for this customer.' => t('No contracts found for this customer.'),
|
||||
'Contract No.' => t('Contract No.'),
|
||||
'Product type' => t('Product type'),
|
||||
'Next invoicing' => t('Next invoicing'),
|
||||
'Amount' => t('Amount'),
|
||||
'Active contracts' => t('Active contracts'),
|
||||
'Total contracts' => t('Total contracts'),
|
||||
'Could not load contracts.' => t('Could not load contracts.'),
|
||||
'Escalations' => t('Escalations'),
|
||||
'Escalations are not available yet.' => t('Escalations are not available yet.'),
|
||||
'Escalated tickets' => t('Escalated tickets'),
|
||||
'No escalated tickets right now.' => t('No escalated tickets right now.'),
|
||||
'Could not load escalated tickets.' => t('Could not load escalated tickets.'),
|
||||
'Escalation code' => t('Escalation code'),
|
||||
'SLA target' => t('SLA target'),
|
||||
'Overdue by' => t('Overdue by'),
|
||||
'day' => t('day'),
|
||||
'days' => t('days'),
|
||||
'Ticket volume' => t('Ticket volume'),
|
||||
'Close rate' => t('Close rate'),
|
||||
'Avg. resolution' => t('Avg. resolution'),
|
||||
'Open backlog' => t('Open backlog'),
|
||||
'Backlog shrinking' => t('Backlog shrinking'),
|
||||
'Backlog growing' => t('Backlog growing'),
|
||||
'unassigned' => t('unassigned'),
|
||||
'Ticket trend' => t('Ticket trend'),
|
||||
'Support efficiency' => t('Support efficiency'),
|
||||
'Risk indicators' => t('Risk indicators'),
|
||||
'vs. prev. period' => t('vs. prev. period'),
|
||||
'Low' => t('Low'),
|
||||
'Medium' => t('Medium'),
|
||||
'High' => t('High'),
|
||||
'flags' => t('flags'),
|
||||
'No controlling data available.' => t('No controlling data available.'),
|
||||
'Ticket volume increase' => t('Ticket volume increase'),
|
||||
'High avg. resolution time' => t('High avg. resolution time'),
|
||||
'Open ticket aging' => t('Open ticket aging'),
|
||||
'Unassigned ticket ratio' => t('Unassigned ticket ratio'),
|
||||
'Could not load controlling dashboard.' => t('Could not load controlling dashboard.'),
|
||||
'Without assignee' => t('Without assignee'),
|
||||
'Created' => t('Created'),
|
||||
'Closed' => t('Closed'),
|
||||
'30 days' => t('30 days'),
|
||||
'90 days' => t('90 days'),
|
||||
'6 months' => t('6 months'),
|
||||
'1 year' => t('1 year'),
|
||||
'Oldest open' => t('Oldest open'),
|
||||
'Open ticket without activity for' => t('Open ticket without activity for'),
|
||||
'hours' => t('hours'),
|
||||
'Open ticket without assigned support user' => t('Open ticket without assigned support user'),
|
||||
'Follow up oldest open ticket' => t('Follow up oldest open ticket'),
|
||||
'Could not load contacts.' => t('Could not load contacts.'),
|
||||
'Could not load tickets.' => t('Could not load tickets.'),
|
||||
'Ticket communication' => t('Ticket communication'),
|
||||
@@ -307,6 +421,38 @@ $locationDisplay = $postCode !== '' && $city !== '' ? $postCode . ' ' . $city :
|
||||
'SOAP' => t('SOAP'),
|
||||
'Activity fallback' => t('Activity fallback'),
|
||||
'Loading...' => t('Loading...'),
|
||||
'Refresh data' => t('Refresh data'),
|
||||
'Active contracts' => t('Active contracts'),
|
||||
'Monthly volume' => t('Monthly volume'),
|
||||
'Support hours' => t('Support hours'),
|
||||
'Next invoicing' => t('Next invoicing'),
|
||||
'Contract overview' => t('Contract overview'),
|
||||
'No contract details available.' => t('No contract details available.'),
|
||||
'Could not load sales dashboard.' => t('Could not load sales dashboard.'),
|
||||
'Quantity' => t('Quantity'),
|
||||
'Unit price' => t('Unit price'),
|
||||
'Line amount' => t('Line amount'),
|
||||
'Line state' => t('Line state'),
|
||||
'Starting date' => t('Starting date'),
|
||||
'Invoicing period' => t('Invoicing period'),
|
||||
'Salesperson' => t('Salesperson'),
|
||||
'Contacts' => t('Contacts'),
|
||||
'positions' => t('positions'),
|
||||
'per month' => t('per month'),
|
||||
'Last invoicing' => t('Last invoicing'),
|
||||
'Close' => t('Close'),
|
||||
'New (30d)' => t('New (30d)'),
|
||||
'Resolved (30d)' => t('Resolved (30d)'),
|
||||
'Avg. age open' => t('Avg. age open'),
|
||||
'vs. prev. 30d' => t('vs. prev. 30d'),
|
||||
'net (30d)' => t('net (30d)'),
|
||||
'critical' => t('critical'),
|
||||
'Analysis' => t('Analysis'),
|
||||
'Open tickets' => t('Open tickets'),
|
||||
'Avg. contract value' => t('Avg. contract value'),
|
||||
'of {total} total' => t('of {total} total'),
|
||||
'in {days} days' => t('in {days} days'),
|
||||
'largest' => t('largest'),
|
||||
]); ?></script>
|
||||
|
||||
<script src="<?php e(assetVersion('vendor/gridjs/gridjs.umd.js')); ?>"></script>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
use MintyPHP\Http\SessionStoreInterface;
|
||||
use MintyPHP\Module\Helpdesk\HelpdeskAuthorizationPolicy;
|
||||
use MintyPHP\Module\Helpdesk\Service\DebitorCacheControl;
|
||||
use MintyPHP\Module\Helpdesk\Service\TicketCommunicationService;
|
||||
use MintyPHP\Router;
|
||||
use MintyPHP\Support\Guard;
|
||||
@@ -19,6 +20,7 @@ if ($request->method() !== 'GET') {
|
||||
|
||||
$customerNo = trim((string) $request->query('customerNo', ''));
|
||||
$customerName = trim((string) $request->query('customerName', ''));
|
||||
$refreshRequested = DebitorCacheControl::parseRefreshFlag($request->query('refresh', ''));
|
||||
|
||||
if ($customerNo === '' || $customerName === '') {
|
||||
http_response_code(400);
|
||||
@@ -29,14 +31,25 @@ if ($customerNo === '' || $customerName === '') {
|
||||
|
||||
$sessionStore = app(SessionStoreInterface::class);
|
||||
$session = $sessionStore->all();
|
||||
$tenantId = (int) (($session['current_tenant']['id'] ?? 0));
|
||||
$tenantScope = $tenantId > 0 ? (string) $tenantId : 'global';
|
||||
$cacheKey = 'module.helpdesk.debitor_comm_cache.v3.' . $tenantScope . '.' . $customerNo;
|
||||
$cacheTtl = 120;
|
||||
$cached = $sessionStore->get($cacheKey);
|
||||
$tenantScope = DebitorCacheControl::resolveTenantScope($session);
|
||||
if ($refreshRequested) {
|
||||
DebitorCacheControl::invalidateDebitorCaches($sessionStore, $tenantScope, $customerNo, true);
|
||||
}
|
||||
|
||||
if (is_array($cached) && isset($cached['fetched_at']) && (time() - (int) $cached['fetched_at']) < $cacheTtl) {
|
||||
Router::json((array) ($cached['payload'] ?? ['ok' => false]));
|
||||
$cacheKey = DebitorCacheControl::communicationKey($tenantScope, $customerNo);
|
||||
$cacheTtl = DebitorCacheControl::TTL_STANDARD_SECONDS;
|
||||
$cached = $sessionStore->get($cacheKey);
|
||||
$cacheUsed = false;
|
||||
|
||||
if (!$refreshRequested && is_array($cached) && isset($cached['fetched_at']) && (time() - (int) $cached['fetched_at']) < $cacheTtl) {
|
||||
$cacheUsed = true;
|
||||
$payload = (array) ($cached['payload'] ?? ['ok' => false]);
|
||||
$meta = is_array($payload['meta'] ?? null) ? $payload['meta'] : [];
|
||||
$meta['cache_used'] = true;
|
||||
$meta['cache_bypassed'] = false;
|
||||
$meta['cache_refreshed'] = false;
|
||||
$payload['meta'] = $meta;
|
||||
Router::json($payload);
|
||||
|
||||
return;
|
||||
}
|
||||
@@ -50,4 +63,10 @@ if (($result['ok'] ?? false) === true) {
|
||||
]);
|
||||
}
|
||||
|
||||
$meta = is_array($result['meta'] ?? null) ? $result['meta'] : [];
|
||||
$meta['cache_used'] = $cacheUsed;
|
||||
$meta['cache_bypassed'] = $refreshRequested;
|
||||
$meta['cache_refreshed'] = $refreshRequested;
|
||||
$result['meta'] = $meta;
|
||||
|
||||
Router::json($result);
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
<?php
|
||||
|
||||
use MintyPHP\Http\SessionStoreInterface;
|
||||
use MintyPHP\Module\Helpdesk\HelpdeskAuthorizationPolicy;
|
||||
use MintyPHP\Module\Helpdesk\Service\DebitorCacheControl;
|
||||
use MintyPHP\Module\Helpdesk\Service\DebitorDetailService;
|
||||
use MintyPHP\Router;
|
||||
use MintyPHP\Support\Guard;
|
||||
@@ -8,25 +10,163 @@ use MintyPHP\Support\Guard;
|
||||
Guard::requireLogin();
|
||||
Guard::requireAbilityOrForbidden(HelpdeskAuthorizationPolicy::ABILITY_ACCESS);
|
||||
|
||||
gridRequireGetRequest();
|
||||
|
||||
$filters = gridParseFiltersFromSchemaFile(__DIR__ . '/debitor-contacts-filter-schema.php');
|
||||
$request = requestInput();
|
||||
if ($request->method() !== 'GET') {
|
||||
http_response_code(405);
|
||||
Router::json(['ok' => false, 'error' => 'method_not_allowed']);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$customerNo = trim((string) $request->query('customerNo', ''));
|
||||
$customerName = trim((string) $request->query('customerName', ''));
|
||||
$customerNo = (string) ($filters['customerNo'] ?? '');
|
||||
$customerName = (string) ($filters['customerName'] ?? '');
|
||||
$refreshRequested = DebitorCacheControl::parseRefreshFlag($request->query('refresh', ''));
|
||||
|
||||
if ($customerNo === '' || $customerName === '') {
|
||||
http_response_code(400);
|
||||
Router::json(['ok' => false, 'error' => 'missing_parameters']);
|
||||
Router::json([
|
||||
'data' => [],
|
||||
'total' => 0,
|
||||
'type_options' => [],
|
||||
'meta' => [
|
||||
'cache_used' => false,
|
||||
'cache_bypassed' => $refreshRequested,
|
||||
'cache_refreshed' => false,
|
||||
],
|
||||
]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$service = app(DebitorDetailService::class);
|
||||
$result = $service->loadContacts($customerNo, $customerName);
|
||||
// --- Session cache (lazy TTL = 300s) ---
|
||||
|
||||
Router::json($result);
|
||||
$sessionStore = app(SessionStoreInterface::class);
|
||||
$session = $sessionStore->all();
|
||||
$tenantScope = DebitorCacheControl::resolveTenantScope($session);
|
||||
if ($refreshRequested) {
|
||||
DebitorCacheControl::invalidateDebitorCaches($sessionStore, $tenantScope, $customerNo, true);
|
||||
}
|
||||
|
||||
$cacheKey = DebitorCacheControl::contactsKey($tenantScope, $customerNo);
|
||||
$cacheTtl = DebitorCacheControl::TTL_STANDARD_SECONDS;
|
||||
$cached = $sessionStore->get($cacheKey);
|
||||
$allContacts = null;
|
||||
$cacheUsed = false;
|
||||
|
||||
if (!$refreshRequested && is_array($cached) && isset($cached['fetched_at']) && (time() - (int) $cached['fetched_at']) < $cacheTtl) {
|
||||
$allContacts = $cached['contacts'] ?? [];
|
||||
$cacheUsed = true;
|
||||
}
|
||||
|
||||
if ($allContacts === null) {
|
||||
$service = app(DebitorDetailService::class);
|
||||
$result = $service->loadContacts($customerNo, $customerName);
|
||||
|
||||
if (!($result['ok'] ?? false)) {
|
||||
Router::json([
|
||||
'data' => [],
|
||||
'total' => 0,
|
||||
'type_options' => [],
|
||||
'meta' => [
|
||||
'cache_used' => $cacheUsed,
|
||||
'cache_bypassed' => $refreshRequested,
|
||||
'cache_refreshed' => $refreshRequested,
|
||||
],
|
||||
]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$allContacts = $result['contacts'] ?? [];
|
||||
|
||||
$sessionStore->set($cacheKey, [
|
||||
'contacts' => $allContacts,
|
||||
'fetched_at' => time(),
|
||||
]);
|
||||
}
|
||||
|
||||
// --- Collect unique types for filter options ---
|
||||
|
||||
$typeSet = [];
|
||||
foreach ($allContacts as $contact) {
|
||||
$type = trim((string) ($contact['Type'] ?? ''));
|
||||
if ($type !== '' && !isset($typeSet[$type])) {
|
||||
$typeSet[$type] = true;
|
||||
}
|
||||
}
|
||||
ksort($typeSet);
|
||||
$typeOptions = [];
|
||||
foreach (array_keys($typeSet) as $type) {
|
||||
$typeOptions[] = ['id' => $type, 'description' => $type];
|
||||
}
|
||||
|
||||
// --- PHP-side filtering ---
|
||||
|
||||
$search = trim((string) ($filters['search'] ?? ''));
|
||||
$typeFilter = trim((string) ($filters['type'] ?? ''));
|
||||
$order = (string) ($filters['order'] ?? 'Name');
|
||||
$dir = (string) ($filters['dir'] ?? 'asc');
|
||||
$limit = (int) ($filters['limit'] ?? 10);
|
||||
$offset = (int) ($filters['offset'] ?? 0);
|
||||
|
||||
$filtered = $allContacts;
|
||||
|
||||
// Search: stripos on Name + Job_Title + E_Mail + Phone_No + Mobile_Phone_No
|
||||
if ($search !== '') {
|
||||
$searchLower = mb_strtolower($search);
|
||||
$filtered = array_values(array_filter($filtered, static function (array $contact) use ($searchLower): bool {
|
||||
$haystack = mb_strtolower(
|
||||
($contact['Name'] ?? '') . ' '
|
||||
. ($contact['Job_Title'] ?? '') . ' '
|
||||
. ($contact['E_Mail'] ?? '') . ' '
|
||||
. ($contact['Phone_No'] ?? '') . ' '
|
||||
. ($contact['Mobile_Phone_No'] ?? '')
|
||||
);
|
||||
|
||||
return str_contains($haystack, $searchLower);
|
||||
}));
|
||||
}
|
||||
|
||||
// Type filter
|
||||
if ($typeFilter !== '') {
|
||||
$filtered = array_values(array_filter($filtered, static function (array $contact) use ($typeFilter): bool {
|
||||
return trim((string) ($contact['Type'] ?? '')) === $typeFilter;
|
||||
}));
|
||||
}
|
||||
|
||||
// --- Sorting ---
|
||||
|
||||
$total = count($filtered);
|
||||
|
||||
usort($filtered, static function (array $a, array $b) use ($order, $dir): int {
|
||||
$va = (string) ($a[$order] ?? '');
|
||||
$vb = (string) ($b[$order] ?? '');
|
||||
$cmp = strnatcasecmp($va, $vb);
|
||||
|
||||
return $dir === 'desc' ? -$cmp : $cmp;
|
||||
});
|
||||
|
||||
// --- Pagination ---
|
||||
|
||||
$rows = array_slice($filtered, $offset, $limit);
|
||||
|
||||
// --- Row preparation ---
|
||||
|
||||
$preparedRows = [];
|
||||
foreach ($rows as $contact) {
|
||||
$preparedRows[] = [
|
||||
'Name' => (string) ($contact['Name'] ?? ''),
|
||||
'Type' => (string) ($contact['Type'] ?? ''),
|
||||
'Job_Title' => (string) ($contact['Job_Title'] ?? ''),
|
||||
'E_Mail' => (string) ($contact['E_Mail'] ?? ''),
|
||||
'Phone_No' => (string) ($contact['Phone_No'] ?? ''),
|
||||
'Mobile_Phone_No' => (string) ($contact['Mobile_Phone_No'] ?? ''),
|
||||
];
|
||||
}
|
||||
|
||||
Router::json([
|
||||
'data' => array_values($preparedRows),
|
||||
'total' => max(0, $total),
|
||||
'type_options' => $typeOptions,
|
||||
'meta' => [
|
||||
'cache_used' => $cacheUsed,
|
||||
'cache_bypassed' => $refreshRequested,
|
||||
'cache_refreshed' => $refreshRequested,
|
||||
],
|
||||
]);
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
return gridFilterSchema([
|
||||
'query' => [
|
||||
'customerNo' => ['type' => 'string'],
|
||||
'customerName' => ['type' => 'string'],
|
||||
'limit' => ['type' => 'int', 'default' => 10, 'min' => 1, 'max' => 100],
|
||||
'offset' => ['type' => 'int', 'default' => 0, 'min' => 0],
|
||||
'search' => ['type' => 'string'],
|
||||
'order' => ['type' => 'order', 'allowed' => ['Name', 'Type', 'Job_Title', 'E_Mail', 'Phone_No'], 'default' => 'Name'],
|
||||
'dir' => ['type' => 'dir', 'default' => 'asc'],
|
||||
'type' => ['type' => 'string', 'default' => ''],
|
||||
],
|
||||
'toolbar' => [
|
||||
[
|
||||
'key' => 'search',
|
||||
'type' => 'text',
|
||||
'label' => 'Search',
|
||||
'input_id' => 'helpdesk-contact-search',
|
||||
'search' => true,
|
||||
'query' => ['type' => 'string'],
|
||||
],
|
||||
[
|
||||
'key' => 'type',
|
||||
'type' => 'select',
|
||||
'label' => 'Type',
|
||||
'input_id' => 'helpdesk-contact-type-filter',
|
||||
'default' => '',
|
||||
'normalize' => 'all_to_empty',
|
||||
'options_key' => 'type_items',
|
||||
'allowed' => [
|
||||
['id' => '', 'description' => 'All'],
|
||||
],
|
||||
'query' => ['type' => 'string'],
|
||||
],
|
||||
],
|
||||
]);
|
||||
@@ -0,0 +1,110 @@
|
||||
<?php
|
||||
|
||||
use MintyPHP\Http\SessionStoreInterface;
|
||||
use MintyPHP\Module\Helpdesk\HelpdeskAuthorizationPolicy;
|
||||
use MintyPHP\Module\Helpdesk\Service\BcODataGateway;
|
||||
use MintyPHP\Module\Helpdesk\Service\DebitorCacheControl;
|
||||
use MintyPHP\Module\Helpdesk\Service\DebitorDetailService;
|
||||
use MintyPHP\Module\Helpdesk\Service\HelpdeskSettingsGateway;
|
||||
use MintyPHP\Router;
|
||||
use MintyPHP\Support\Guard;
|
||||
|
||||
Guard::requireLogin();
|
||||
Guard::requireAbilityOrForbidden(HelpdeskAuthorizationPolicy::ABILITY_ACCESS);
|
||||
|
||||
$request = requestInput();
|
||||
if ($request->method() !== 'GET') {
|
||||
http_response_code(405);
|
||||
Router::json(['ok' => false, 'error' => 'method_not_allowed']);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$customerNo = trim((string) $request->query('customerNo', ''));
|
||||
$customerName = trim((string) $request->query('customerName', ''));
|
||||
$periodDays = (int) $request->query('periodDays', '90');
|
||||
$refreshRequested = DebitorCacheControl::parseRefreshFlag($request->query('refresh', ''));
|
||||
|
||||
if ($customerNo === '' || $customerName === '') {
|
||||
http_response_code(400);
|
||||
Router::json(['ok' => false, 'error' => 'missing_parameters']);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$allowedPeriods = [30, 90, 180, 365];
|
||||
if (!in_array($periodDays, $allowedPeriods, true)) {
|
||||
$periodDays = 90;
|
||||
}
|
||||
|
||||
$sessionStore = app(SessionStoreInterface::class);
|
||||
$session = $sessionStore->all();
|
||||
$tenantScope = DebitorCacheControl::resolveTenantScope($session);
|
||||
if ($refreshRequested) {
|
||||
DebitorCacheControl::invalidateDebitorCaches($sessionStore, $tenantScope, $customerNo, true);
|
||||
}
|
||||
|
||||
// --- Load controlling tickets (period-independent cache: full 500 tickets) ---
|
||||
|
||||
$ticketsCacheKey = DebitorCacheControl::controllingTicketsKey($tenantScope, $customerNo);
|
||||
$cacheTtl = DebitorCacheControl::TTL_STANDARD_SECONDS;
|
||||
$cached = $sessionStore->get($ticketsCacheKey);
|
||||
$tickets = null;
|
||||
$ticketsCacheUsed = false;
|
||||
|
||||
if (!$refreshRequested && is_array($cached) && isset($cached['fetched_at']) && (time() - (int) $cached['fetched_at']) < $cacheTtl) {
|
||||
$tickets = is_array($cached['tickets'] ?? null) ? $cached['tickets'] : [];
|
||||
$ticketsCacheUsed = true;
|
||||
}
|
||||
|
||||
if ($tickets === null) {
|
||||
$gateway = app(BcODataGateway::class);
|
||||
|
||||
try {
|
||||
$tickets = $gateway->getTicketsForControlling($customerNo);
|
||||
} catch (\Throwable $e) {
|
||||
Router::json([
|
||||
'ok' => false,
|
||||
'error' => 'Failed to load controlling tickets: ' . $e->getMessage(),
|
||||
]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (!is_array($tickets)) {
|
||||
$tickets = [];
|
||||
}
|
||||
|
||||
$sessionStore->set($ticketsCacheKey, [
|
||||
'tickets' => $tickets,
|
||||
'fetched_at' => time(),
|
||||
]);
|
||||
}
|
||||
|
||||
// --- Load contract data (reuse existing contracts cache from Sales tab) ---
|
||||
|
||||
// --- Load risk config ---
|
||||
|
||||
$settingsGateway = app(HelpdeskSettingsGateway::class);
|
||||
$riskConfig = $settingsGateway->getControllingRiskConfig();
|
||||
|
||||
// --- Build dashboard ---
|
||||
|
||||
$dashboard = DebitorDetailService::buildControllingDashboard(
|
||||
$tickets,
|
||||
$periodDays,
|
||||
$riskConfig
|
||||
);
|
||||
|
||||
Router::json([
|
||||
'ok' => true,
|
||||
'kpis' => $dashboard['kpis'],
|
||||
'trend' => $dashboard['trend'],
|
||||
'efficiency' => $dashboard['efficiency'],
|
||||
'risk_indicators' => $dashboard['risk_indicators'],
|
||||
'meta' => array_merge($dashboard['meta'], [
|
||||
'cache_used' => $ticketsCacheUsed,
|
||||
'cache_bypassed' => $refreshRequested,
|
||||
'cache_refreshed' => $refreshRequested,
|
||||
]),
|
||||
]);
|
||||
108
modules/helpdesk/pages/helpdesk/debitor-overview-data().php
Normal file
108
modules/helpdesk/pages/helpdesk/debitor-overview-data().php
Normal file
@@ -0,0 +1,108 @@
|
||||
<?php
|
||||
|
||||
use MintyPHP\Http\SessionStoreInterface;
|
||||
use MintyPHP\Module\Helpdesk\HelpdeskAuthorizationPolicy;
|
||||
use MintyPHP\Module\Helpdesk\Service\DebitorCacheControl;
|
||||
use MintyPHP\Module\Helpdesk\Service\DebitorDetailService;
|
||||
use MintyPHP\Router;
|
||||
use MintyPHP\Support\Guard;
|
||||
|
||||
Guard::requireLogin();
|
||||
Guard::requireAbilityOrForbidden(HelpdeskAuthorizationPolicy::ABILITY_ACCESS);
|
||||
|
||||
gridRequireGetRequest();
|
||||
|
||||
$request = requestInput();
|
||||
$customerNo = trim((string) $request->query('customerNo', ''));
|
||||
$customerName = trim((string) $request->query('customerName', ''));
|
||||
$refreshRequested = DebitorCacheControl::parseRefreshFlag($request->query('refresh', ''));
|
||||
|
||||
if ($customerNo === '' || $customerName === '') {
|
||||
http_response_code(400);
|
||||
Router::json(['ok' => false, 'error' => 'Missing parameters']);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$sessionStore = app(SessionStoreInterface::class);
|
||||
$session = $sessionStore->all();
|
||||
$tenantScope = DebitorCacheControl::resolveTenantScope($session);
|
||||
if ($refreshRequested) {
|
||||
DebitorCacheControl::invalidateDebitorCaches($sessionStore, $tenantScope, $customerNo, true);
|
||||
}
|
||||
|
||||
$cacheKey = DebitorCacheControl::ticketsKey($tenantScope, $customerNo);
|
||||
$cacheTtl = DebitorCacheControl::TTL_STANDARD_SECONDS;
|
||||
$cached = $sessionStore->get($cacheKey);
|
||||
$tickets = null;
|
||||
$cacheUsed = false;
|
||||
|
||||
if (!$refreshRequested && is_array($cached) && isset($cached['fetched_at']) && (time() - (int) $cached['fetched_at']) < $cacheTtl) {
|
||||
$tickets = $cached['tickets'] ?? [];
|
||||
$cacheUsed = true;
|
||||
}
|
||||
|
||||
if ($tickets === null) {
|
||||
$service = app(DebitorDetailService::class);
|
||||
$ticketsResult = $service->loadTickets($customerNo, $customerName);
|
||||
|
||||
if (!($ticketsResult['ok'] ?? false)) {
|
||||
Router::json(['ok' => false, 'error' => $ticketsResult['error'] ?? 'Failed to load tickets']);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$tickets = $ticketsResult['tickets'] ?? [];
|
||||
if (!is_array($tickets)) {
|
||||
$tickets = [];
|
||||
}
|
||||
|
||||
$sessionStore->set($cacheKey, [
|
||||
'tickets' => $tickets,
|
||||
'fetched_at' => time(),
|
||||
]);
|
||||
}
|
||||
|
||||
if (!is_array($tickets)) {
|
||||
$tickets = [];
|
||||
}
|
||||
|
||||
$ticketSortStamp = static function (array $ticket): int {
|
||||
$activity = trim((string) ($ticket['Last_Activity_Date'] ?? ''));
|
||||
if ($activity !== '' && $activity !== '0001-01-01T00:00:00Z') {
|
||||
$activityTs = strtotime($activity);
|
||||
if (is_int($activityTs)) {
|
||||
return $activityTs;
|
||||
}
|
||||
}
|
||||
|
||||
$created = trim((string) ($ticket['Created_On'] ?? ''));
|
||||
if ($created !== '' && $created !== '0001-01-01T00:00:00Z') {
|
||||
$createdTs = strtotime($created);
|
||||
if (is_int($createdTs)) {
|
||||
return $createdTs;
|
||||
}
|
||||
}
|
||||
|
||||
return 0;
|
||||
};
|
||||
|
||||
$recentActivity = array_values($tickets);
|
||||
usort($recentActivity, static function (array $a, array $b) use ($ticketSortStamp): int {
|
||||
$sa = $ticketSortStamp($a);
|
||||
$sb = $ticketSortStamp($b);
|
||||
|
||||
return $sb <=> $sa;
|
||||
});
|
||||
$recentActivity = array_slice($recentActivity, 0, 5);
|
||||
|
||||
Router::json([
|
||||
'ok' => true,
|
||||
'recent_activity' => $recentActivity,
|
||||
'meta' => [
|
||||
'total_tickets' => count($tickets),
|
||||
'cache_used' => $cacheUsed,
|
||||
'cache_bypassed' => $refreshRequested,
|
||||
'cache_refreshed' => $refreshRequested,
|
||||
],
|
||||
]);
|
||||
@@ -0,0 +1,73 @@
|
||||
<?php
|
||||
|
||||
use MintyPHP\Http\SessionStoreInterface;
|
||||
use MintyPHP\Module\Helpdesk\HelpdeskAuthorizationPolicy;
|
||||
use MintyPHP\Module\Helpdesk\Service\DebitorCacheControl;
|
||||
use MintyPHP\Module\Helpdesk\Service\DebitorDetailService;
|
||||
use MintyPHP\Router;
|
||||
use MintyPHP\Support\Guard;
|
||||
|
||||
Guard::requireLogin();
|
||||
Guard::requireAbilityOrForbidden(HelpdeskAuthorizationPolicy::ABILITY_ACCESS);
|
||||
|
||||
$request = requestInput();
|
||||
if ($request->method() !== 'GET') {
|
||||
http_response_code(405);
|
||||
Router::json(['ok' => false, 'error' => 'method_not_allowed']);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$customerNo = trim((string) $request->query('customerNo', ''));
|
||||
$customerName = trim((string) $request->query('customerName', ''));
|
||||
$refreshRequested = DebitorCacheControl::parseRefreshFlag($request->query('refresh', ''));
|
||||
|
||||
if ($customerNo === '' || $customerName === '') {
|
||||
http_response_code(400);
|
||||
Router::json(['ok' => false, 'error' => 'missing_parameters']);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$sessionStore = app(SessionStoreInterface::class);
|
||||
$session = $sessionStore->all();
|
||||
$tenantScope = DebitorCacheControl::resolveTenantScope($session);
|
||||
if ($refreshRequested) {
|
||||
DebitorCacheControl::invalidateDebitorCaches($sessionStore, $tenantScope, $customerNo, true);
|
||||
}
|
||||
|
||||
$cacheKey = DebitorCacheControl::contractLinesKey($tenantScope, $customerNo);
|
||||
$cacheTtl = DebitorCacheControl::TTL_STANDARD_SECONDS;
|
||||
$cached = $sessionStore->get($cacheKey);
|
||||
$cacheUsed = false;
|
||||
|
||||
if (!$refreshRequested && is_array($cached) && isset($cached['fetched_at']) && (time() - (int) $cached['fetched_at']) < $cacheTtl) {
|
||||
$cacheUsed = true;
|
||||
$payload = is_array($cached['payload'] ?? null) ? $cached['payload'] : ['ok' => false];
|
||||
$payload['meta'] = [
|
||||
'cache_used' => true,
|
||||
'cache_bypassed' => false,
|
||||
'cache_refreshed' => false,
|
||||
];
|
||||
Router::json($payload);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$service = app(DebitorDetailService::class);
|
||||
$result = $service->loadSalesDashboard($customerNo, $customerName);
|
||||
|
||||
if (($result['ok'] ?? false) === true) {
|
||||
$sessionStore->set($cacheKey, [
|
||||
'payload' => $result,
|
||||
'fetched_at' => time(),
|
||||
]);
|
||||
}
|
||||
|
||||
$result['meta'] = [
|
||||
'cache_used' => $cacheUsed,
|
||||
'cache_bypassed' => $refreshRequested,
|
||||
'cache_refreshed' => $refreshRequested,
|
||||
];
|
||||
|
||||
Router::json($result);
|
||||
@@ -1,6 +1,7 @@
|
||||
<?php
|
||||
|
||||
use MintyPHP\Module\Helpdesk\HelpdeskAuthorizationPolicy;
|
||||
use MintyPHP\Module\Helpdesk\Service\DebitorCacheControl;
|
||||
use MintyPHP\Module\Helpdesk\Service\DebitorDetailService;
|
||||
use MintyPHP\Http\SessionStoreInterface;
|
||||
use MintyPHP\Router;
|
||||
@@ -14,6 +15,7 @@ gridRequireGetRequest();
|
||||
$request = requestInput();
|
||||
$customerNo = trim((string) $request->query('customerNo', ''));
|
||||
$customerName = trim((string) $request->query('customerName', ''));
|
||||
$refreshRequested = DebitorCacheControl::parseRefreshFlag($request->query('refresh', ''));
|
||||
|
||||
if ($customerNo === '' || $customerName === '') {
|
||||
http_response_code(400);
|
||||
@@ -25,18 +27,26 @@ if ($customerNo === '' || $customerName === '') {
|
||||
// Reuse session cache from tickets endpoint when available (avoids duplicate OData call)
|
||||
$sessionStore = app(SessionStoreInterface::class);
|
||||
$session = $sessionStore->all();
|
||||
$tenantId = (int) (($session['current_tenant']['id'] ?? 0));
|
||||
$tenantScope = $tenantId > 0 ? (string) $tenantId : 'global';
|
||||
$cacheKey = 'module.helpdesk.tickets_cache.' . $tenantScope . '.' . $customerNo;
|
||||
$cacheTtl = 120;
|
||||
$tenantScope = DebitorCacheControl::resolveTenantScope($session);
|
||||
if ($refreshRequested) {
|
||||
DebitorCacheControl::invalidateDebitorCaches($sessionStore, $tenantScope, $customerNo, true);
|
||||
}
|
||||
|
||||
$cacheKey = DebitorCacheControl::ticketsKey($tenantScope, $customerNo);
|
||||
$cacheTtl = DebitorCacheControl::TTL_STANDARD_SECONDS;
|
||||
$cached = $sessionStore->get($cacheKey);
|
||||
|
||||
if (is_array($cached) && isset($cached['fetched_at']) && (time() - (int) $cached['fetched_at']) < $cacheTtl) {
|
||||
if (!$refreshRequested && is_array($cached) && isset($cached['fetched_at']) && (time() - (int) $cached['fetched_at']) < $cacheTtl) {
|
||||
$cachedTickets = $cached['tickets'] ?? [];
|
||||
if (!is_array($cachedTickets)) {
|
||||
$cachedTickets = [];
|
||||
}
|
||||
$summary = DebitorDetailService::summarizeTickets($cachedTickets);
|
||||
$summary['meta'] = [
|
||||
'cache_used' => true,
|
||||
'cache_bypassed' => false,
|
||||
'cache_refreshed' => false,
|
||||
];
|
||||
Router::json($summary);
|
||||
|
||||
return;
|
||||
@@ -63,5 +73,10 @@ $sessionStore->set($cacheKey, [
|
||||
]);
|
||||
|
||||
$summary = DebitorDetailService::summarizeTickets($tickets);
|
||||
$summary['meta'] = [
|
||||
'cache_used' => false,
|
||||
'cache_bypassed' => $refreshRequested,
|
||||
'cache_refreshed' => $refreshRequested,
|
||||
];
|
||||
|
||||
Router::json($summary);
|
||||
|
||||
@@ -0,0 +1,233 @@
|
||||
<?php
|
||||
|
||||
use MintyPHP\Http\SessionStoreInterface;
|
||||
use MintyPHP\Module\Helpdesk\HelpdeskAuthorizationPolicy;
|
||||
use MintyPHP\Module\Helpdesk\Service\DebitorCacheControl;
|
||||
use MintyPHP\Module\Helpdesk\Service\DebitorDetailService;
|
||||
use MintyPHP\Module\Helpdesk\Service\HelpdeskSettingsGateway;
|
||||
use MintyPHP\Module\Helpdesk\Service\SystemRecommendationEngine;
|
||||
use MintyPHP\Router;
|
||||
use MintyPHP\Support\Guard;
|
||||
|
||||
Guard::requireLogin();
|
||||
Guard::requireAbilityOrForbidden(HelpdeskAuthorizationPolicy::ABILITY_ACCESS);
|
||||
|
||||
gridRequireGetRequest();
|
||||
|
||||
$request = requestInput();
|
||||
$customerNo = trim((string) $request->query('customerNo', ''));
|
||||
$customerName = trim((string) $request->query('customerName', ''));
|
||||
$refreshRequested = DebitorCacheControl::parseRefreshFlag($request->query('refresh', ''));
|
||||
|
||||
if ($customerNo === '' || $customerName === '') {
|
||||
http_response_code(400);
|
||||
Router::json(['ok' => false, 'error' => 'Missing parameters']);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$sessionStore = app(SessionStoreInterface::class);
|
||||
$session = $sessionStore->all();
|
||||
$tenantScope = DebitorCacheControl::resolveTenantScope($session);
|
||||
if ($refreshRequested) {
|
||||
DebitorCacheControl::invalidateDebitorCaches($sessionStore, $tenantScope, $customerNo, true);
|
||||
}
|
||||
|
||||
$cacheKey = DebitorCacheControl::ticketsKey($tenantScope, $customerNo);
|
||||
$cacheTtl = DebitorCacheControl::TTL_STANDARD_SECONDS;
|
||||
$cached = $sessionStore->get($cacheKey);
|
||||
$tickets = null;
|
||||
$sourceCacheUsed = false;
|
||||
|
||||
if (!$refreshRequested && is_array($cached) && isset($cached['fetched_at']) && (time() - (int) $cached['fetched_at']) < $cacheTtl) {
|
||||
$tickets = $cached['tickets'] ?? [];
|
||||
$sourceCacheUsed = true;
|
||||
}
|
||||
|
||||
if ($tickets === null) {
|
||||
$service = app(DebitorDetailService::class);
|
||||
$ticketsResult = $service->loadTickets($customerNo, $customerName);
|
||||
|
||||
if (!($ticketsResult['ok'] ?? false)) {
|
||||
Router::json([
|
||||
'ok' => false,
|
||||
'error' => (string) ($ticketsResult['error'] ?? 'Failed to load tickets'),
|
||||
]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$tickets = $ticketsResult['tickets'] ?? [];
|
||||
if (!is_array($tickets)) {
|
||||
$tickets = [];
|
||||
}
|
||||
|
||||
$sessionStore->set($cacheKey, [
|
||||
'tickets' => $tickets,
|
||||
'fetched_at' => time(),
|
||||
]);
|
||||
}
|
||||
|
||||
if (!is_array($tickets)) {
|
||||
$tickets = [];
|
||||
}
|
||||
|
||||
$service = app(DebitorDetailService::class);
|
||||
$escalationCacheKey = DebitorCacheControl::escalationKey($tenantScope, $customerNo);
|
||||
$escalationCached = $sessionStore->get($escalationCacheKey);
|
||||
$escalationSourceCacheUsed = false;
|
||||
$escalationPayload = null;
|
||||
|
||||
if (!$refreshRequested && is_array($escalationCached) && isset($escalationCached['fetched_at']) && (time() - (int) $escalationCached['fetched_at']) < $cacheTtl) {
|
||||
$escalationPayload = [
|
||||
'ok' => (bool) ($escalationCached['ok'] ?? false),
|
||||
'overdue_count' => (int) ($escalationCached['overdue_count'] ?? 0),
|
||||
'entries' => is_array($escalationCached['entries'] ?? null) ? $escalationCached['entries'] : [],
|
||||
'matches' => is_array($escalationCached['matches'] ?? null) ? $escalationCached['matches'] : [],
|
||||
'tickets_considered' => (int) ($escalationCached['tickets_considered'] ?? 0),
|
||||
'tickets_matched' => (int) ($escalationCached['tickets_matched'] ?? 0),
|
||||
'definitions_count' => (int) ($escalationCached['definitions_count'] ?? 0),
|
||||
'error' => (string) ($escalationCached['error'] ?? ''),
|
||||
];
|
||||
$escalationSourceCacheUsed = true;
|
||||
}
|
||||
|
||||
if ($escalationPayload === null) {
|
||||
$escalationResult = $service->loadEscalationHealth($customerNo, $tickets);
|
||||
$escalationPayload = $escalationResult;
|
||||
|
||||
$sessionStore->set($escalationCacheKey, [
|
||||
'ok' => (bool) ($escalationResult['ok'] ?? false),
|
||||
'overdue_count' => (int) ($escalationResult['overdue_count'] ?? 0),
|
||||
'entries' => is_array($escalationResult['entries'] ?? null) ? $escalationResult['entries'] : [],
|
||||
'matches' => is_array($escalationResult['matches'] ?? null) ? $escalationResult['matches'] : [],
|
||||
'tickets_considered' => (int) ($escalationResult['tickets_considered'] ?? 0),
|
||||
'tickets_matched' => (int) ($escalationResult['tickets_matched'] ?? 0),
|
||||
'definitions_count' => (int) ($escalationResult['definitions_count'] ?? 0),
|
||||
'error' => (string) ($escalationResult['error'] ?? ''),
|
||||
'fetched_at' => time(),
|
||||
]);
|
||||
}
|
||||
|
||||
$dashboard = DebitorDetailService::buildSupportDashboard(
|
||||
$tickets,
|
||||
48,
|
||||
(($escalationPayload['ok'] ?? false) ? $escalationPayload : null)
|
||||
);
|
||||
$settingsGateway = app(HelpdeskSettingsGateway::class);
|
||||
$recommendationConfigEnvelope = $settingsGateway->getSystemRecommendationsConfigEnvelope();
|
||||
$recommendationConfig = is_array($recommendationConfigEnvelope['config'] ?? null)
|
||||
? $recommendationConfigEnvelope['config']
|
||||
: [];
|
||||
$recommendationEngine = app(SystemRecommendationEngine::class);
|
||||
$recommendationResult = $recommendationEngine->buildRecommendations(
|
||||
$tickets,
|
||||
(($escalationPayload['ok'] ?? false) ? $escalationPayload : null),
|
||||
$recommendationConfig
|
||||
);
|
||||
$recommendations = is_array($recommendationResult['recommendations'] ?? null)
|
||||
? $recommendationResult['recommendations']
|
||||
: [];
|
||||
$recommendationsMeta = is_array($recommendationResult['meta'] ?? null)
|
||||
? $recommendationResult['meta']
|
||||
: [];
|
||||
$meta = $dashboard['meta'];
|
||||
$meta['source_cache_used'] = $sourceCacheUsed;
|
||||
$meta['escalation_source_cache_used'] = $escalationSourceCacheUsed;
|
||||
$meta['cache_used'] = ($sourceCacheUsed || $escalationSourceCacheUsed);
|
||||
$meta['cache_bypassed'] = $refreshRequested;
|
||||
$meta['cache_refreshed'] = $refreshRequested;
|
||||
|
||||
$contractsCacheKey = DebitorCacheControl::contractsKey($tenantScope, $customerNo);
|
||||
$contractsCached = $sessionStore->get($contractsCacheKey);
|
||||
$contractsSourceCacheUsed = false;
|
||||
$contractsPayload = null;
|
||||
|
||||
if (!$refreshRequested && is_array($contractsCached) && isset($contractsCached['fetched_at']) && (time() - (int) $contractsCached['fetched_at']) < $cacheTtl) {
|
||||
$contractsPayload = [
|
||||
'ok' => true,
|
||||
'entries' => is_array($contractsCached['entries'] ?? null) ? $contractsCached['entries'] : [],
|
||||
'summary' => is_array($contractsCached['summary'] ?? null) ? $contractsCached['summary'] : [
|
||||
'total_contracts' => 0,
|
||||
'active_contracts' => 0,
|
||||
'product_types' => [],
|
||||
],
|
||||
];
|
||||
$contractsSourceCacheUsed = true;
|
||||
}
|
||||
|
||||
if ($contractsPayload === null) {
|
||||
$contractsResult = $service->loadContracts($customerNo);
|
||||
if ($contractsResult['ok'] ?? false) {
|
||||
$entries = is_array($contractsResult['entries'] ?? null) ? $contractsResult['entries'] : [];
|
||||
$summary = is_array($contractsResult['summary'] ?? null) ? $contractsResult['summary'] : [
|
||||
'total_contracts' => 0,
|
||||
'active_contracts' => 0,
|
||||
'product_types' => [],
|
||||
];
|
||||
|
||||
$contractsPayload = [
|
||||
'ok' => true,
|
||||
'entries' => $entries,
|
||||
'summary' => $summary,
|
||||
];
|
||||
|
||||
$sessionStore->set($contractsCacheKey, [
|
||||
'entries' => $entries,
|
||||
'summary' => $summary,
|
||||
'fetched_at' => time(),
|
||||
]);
|
||||
} else {
|
||||
$contractsPayload = [
|
||||
'ok' => false,
|
||||
'entries' => [],
|
||||
'summary' => [
|
||||
'total_contracts' => 0,
|
||||
'active_contracts' => 0,
|
||||
'product_types' => [],
|
||||
],
|
||||
'error' => (string) ($contractsResult['error'] ?? 'Failed to load contracts'),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
$meta['cache_used'] = ($sourceCacheUsed || $escalationSourceCacheUsed || $contractsSourceCacheUsed);
|
||||
|
||||
Router::json([
|
||||
'ok' => true,
|
||||
'health' => $dashboard['health'],
|
||||
'recommendations' => $recommendations,
|
||||
'recommendations_meta' => [
|
||||
'config_source' => (string) ($recommendationConfigEnvelope['source'] ?? 'default'),
|
||||
'applied_rules' => is_array($recommendationsMeta['applied_rules'] ?? null) ? $recommendationsMeta['applied_rules'] : [],
|
||||
'max_items' => (int) ($recommendationsMeta['max_items'] ?? (($recommendationConfig['max_items'] ?? 5))),
|
||||
'escalation_data_available' => (bool) ($recommendationsMeta['escalation_data_available'] ?? false),
|
||||
'cache_used' => ($sourceCacheUsed || $escalationSourceCacheUsed || $contractsSourceCacheUsed),
|
||||
'cache_bypassed' => $refreshRequested,
|
||||
'cache_refreshed' => $refreshRequested,
|
||||
],
|
||||
'actions' => $recommendations,
|
||||
'meta' => $meta,
|
||||
'contracts' => $contractsPayload['entries'],
|
||||
'contracts_summary' => $contractsPayload['summary'],
|
||||
'contracts_meta' => [
|
||||
'available' => (bool) ($contractsPayload['ok'] ?? false),
|
||||
'source_cache_used' => $contractsSourceCacheUsed,
|
||||
'cache_used' => $contractsSourceCacheUsed,
|
||||
'cache_bypassed' => $refreshRequested,
|
||||
'cache_refreshed' => $refreshRequested,
|
||||
'error' => (string) ($contractsPayload['error'] ?? ''),
|
||||
],
|
||||
'escalation_entries' => is_array($escalationPayload['entries'] ?? null) ? $escalationPayload['entries'] : [],
|
||||
'escalation_meta' => [
|
||||
'available' => (bool) ($escalationPayload['ok'] ?? false),
|
||||
'source_cache_used' => $escalationSourceCacheUsed,
|
||||
'cache_used' => $escalationSourceCacheUsed,
|
||||
'cache_bypassed' => $refreshRequested,
|
||||
'cache_refreshed' => $refreshRequested,
|
||||
'error' => (string) ($escalationPayload['error'] ?? ''),
|
||||
'tickets_considered' => (int) ($escalationPayload['tickets_considered'] ?? 0),
|
||||
'tickets_matched' => (int) ($escalationPayload['tickets_matched'] ?? 0),
|
||||
'definitions_count' => (int) ($escalationPayload['definitions_count'] ?? 0),
|
||||
],
|
||||
]);
|
||||
@@ -1,6 +1,7 @@
|
||||
<?php
|
||||
|
||||
use MintyPHP\Module\Helpdesk\HelpdeskAuthorizationPolicy;
|
||||
use MintyPHP\Module\Helpdesk\Service\DebitorCacheControl;
|
||||
use MintyPHP\Module\Helpdesk\Service\DebitorDetailService;
|
||||
use MintyPHP\Http\SessionStoreInterface;
|
||||
use MintyPHP\Router;
|
||||
@@ -18,11 +19,17 @@ $filters = gridParseFilters(requestInput()->queryAll(), [
|
||||
|
||||
$customerNo = (string) ($filters['customerNo'] ?? '');
|
||||
$customerName = (string) ($filters['customerName'] ?? '');
|
||||
$refreshRequested = DebitorCacheControl::parseRefreshFlag(requestInput()->query('refresh', ''));
|
||||
|
||||
if ($customerNo === '' || $customerName === '') {
|
||||
Router::json([
|
||||
'ok' => false,
|
||||
'categories' => [],
|
||||
'meta' => [
|
||||
'cache_used' => false,
|
||||
'cache_bypassed' => $refreshRequested,
|
||||
'cache_refreshed' => false,
|
||||
],
|
||||
]);
|
||||
|
||||
return;
|
||||
@@ -30,15 +37,20 @@ if ($customerNo === '' || $customerName === '') {
|
||||
|
||||
$sessionStore = app(SessionStoreInterface::class);
|
||||
$session = $sessionStore->all();
|
||||
$tenantId = (int) (($session['current_tenant']['id'] ?? 0));
|
||||
$tenantScope = $tenantId > 0 ? (string) $tenantId : 'global';
|
||||
$cacheKey = 'module.helpdesk.tickets_cache.' . $tenantScope . '.' . $customerNo;
|
||||
$cacheTtl = 120;
|
||||
$tenantScope = DebitorCacheControl::resolveTenantScope($session);
|
||||
if ($refreshRequested) {
|
||||
DebitorCacheControl::invalidateDebitorCaches($sessionStore, $tenantScope, $customerNo, true);
|
||||
}
|
||||
|
||||
$cacheKey = DebitorCacheControl::ticketsKey($tenantScope, $customerNo);
|
||||
$cacheTtl = DebitorCacheControl::TTL_STANDARD_SECONDS;
|
||||
$cached = $sessionStore->get($cacheKey);
|
||||
$allTickets = null;
|
||||
$cacheUsed = false;
|
||||
|
||||
if (is_array($cached) && isset($cached['fetched_at']) && (time() - (int) $cached['fetched_at']) < $cacheTtl) {
|
||||
if (!$refreshRequested && is_array($cached) && isset($cached['fetched_at']) && (time() - (int) $cached['fetched_at']) < $cacheTtl) {
|
||||
$allTickets = $cached['tickets'] ?? [];
|
||||
$cacheUsed = true;
|
||||
}
|
||||
|
||||
if ($allTickets === null) {
|
||||
@@ -49,6 +61,11 @@ if ($allTickets === null) {
|
||||
Router::json([
|
||||
'ok' => false,
|
||||
'categories' => [],
|
||||
'meta' => [
|
||||
'cache_used' => $cacheUsed,
|
||||
'cache_bypassed' => $refreshRequested,
|
||||
'cache_refreshed' => $refreshRequested,
|
||||
],
|
||||
]);
|
||||
|
||||
return;
|
||||
@@ -80,4 +97,9 @@ usort($categoryValues, 'strnatcasecmp');
|
||||
Router::json([
|
||||
'ok' => true,
|
||||
'categories' => array_values($categoryValues),
|
||||
'meta' => [
|
||||
'cache_used' => $cacheUsed,
|
||||
'cache_bypassed' => $refreshRequested,
|
||||
'cache_refreshed' => $refreshRequested,
|
||||
],
|
||||
]);
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
<?php
|
||||
|
||||
use MintyPHP\Module\Helpdesk\HelpdeskAuthorizationPolicy;
|
||||
use MintyPHP\Module\Helpdesk\Service\DebitorCacheControl;
|
||||
use MintyPHP\Module\Helpdesk\Service\DebitorDetailService;
|
||||
use MintyPHP\Http\SessionStoreInterface;
|
||||
use MintyPHP\Router;
|
||||
use MintyPHP\Support\Guard;
|
||||
|
||||
Guard::requireLogin();
|
||||
@@ -11,12 +13,22 @@ Guard::requireAbilityOrForbidden(HelpdeskAuthorizationPolicy::ABILITY_ACCESS);
|
||||
gridRequireGetRequest();
|
||||
|
||||
$filters = gridParseFiltersFromSchemaFile(__DIR__ . '/debitor-tickets-filter-schema.php');
|
||||
$request = requestInput();
|
||||
|
||||
$customerNo = (string) ($filters['customerNo'] ?? '');
|
||||
$customerName = (string) ($filters['customerName'] ?? '');
|
||||
$refreshRequested = DebitorCacheControl::parseRefreshFlag($request->query('refresh', ''));
|
||||
|
||||
if ($customerNo === '' || $customerName === '') {
|
||||
gridJsonDataResult([], 0);
|
||||
Router::json([
|
||||
'data' => [],
|
||||
'total' => 0,
|
||||
'meta' => [
|
||||
'cache_used' => false,
|
||||
'cache_bypassed' => $refreshRequested,
|
||||
'cache_refreshed' => false,
|
||||
],
|
||||
]);
|
||||
|
||||
return;
|
||||
}
|
||||
@@ -25,15 +37,20 @@ if ($customerNo === '' || $customerName === '') {
|
||||
|
||||
$sessionStore = app(SessionStoreInterface::class);
|
||||
$session = $sessionStore->all();
|
||||
$tenantId = (int) (($session['current_tenant']['id'] ?? 0));
|
||||
$tenantScope = $tenantId > 0 ? (string) $tenantId : 'global';
|
||||
$cacheKey = 'module.helpdesk.tickets_cache.' . $tenantScope . '.' . $customerNo;
|
||||
$cacheTtl = 120;
|
||||
$tenantScope = DebitorCacheControl::resolveTenantScope($session);
|
||||
if ($refreshRequested) {
|
||||
DebitorCacheControl::invalidateDebitorCaches($sessionStore, $tenantScope, $customerNo, true);
|
||||
}
|
||||
|
||||
$cacheKey = DebitorCacheControl::ticketsKey($tenantScope, $customerNo);
|
||||
$cacheTtl = DebitorCacheControl::TTL_STANDARD_SECONDS;
|
||||
$cached = $sessionStore->get($cacheKey);
|
||||
$allTickets = null;
|
||||
$cacheUsed = false;
|
||||
|
||||
if (is_array($cached) && isset($cached['fetched_at']) && (time() - (int) $cached['fetched_at']) < $cacheTtl) {
|
||||
if (!$refreshRequested && is_array($cached) && isset($cached['fetched_at']) && (time() - (int) $cached['fetched_at']) < $cacheTtl) {
|
||||
$allTickets = $cached['tickets'] ?? [];
|
||||
$cacheUsed = true;
|
||||
}
|
||||
|
||||
if ($allTickets === null) {
|
||||
@@ -41,7 +58,15 @@ if ($allTickets === null) {
|
||||
$result = $service->loadTickets($customerNo, $customerName);
|
||||
|
||||
if (!($result['ok'] ?? false)) {
|
||||
gridJsonDataResult([], 0);
|
||||
Router::json([
|
||||
'data' => [],
|
||||
'total' => 0,
|
||||
'meta' => [
|
||||
'cache_used' => $cacheUsed,
|
||||
'cache_bypassed' => $refreshRequested,
|
||||
'cache_refreshed' => $refreshRequested,
|
||||
],
|
||||
]);
|
||||
|
||||
return;
|
||||
}
|
||||
@@ -176,4 +201,12 @@ foreach ($rows as $ticket) {
|
||||
];
|
||||
}
|
||||
|
||||
gridJsonDataResult($preparedRows, $total);
|
||||
Router::json([
|
||||
'data' => array_values($preparedRows),
|
||||
'total' => max(0, $total),
|
||||
'meta' => [
|
||||
'cache_used' => $cacheUsed,
|
||||
'cache_bypassed' => $refreshRequested,
|
||||
'cache_refreshed' => $refreshRequested,
|
||||
],
|
||||
]);
|
||||
|
||||
@@ -33,6 +33,7 @@ if ($request->isMethod('POST')) {
|
||||
$oauthClientId = trim((string) ($post['oauth_client_id'] ?? ''));
|
||||
$oauthClientSecret = trim((string) ($post['oauth_client_secret'] ?? ''));
|
||||
$oauthTokenEndpoint = trim((string) ($post['oauth_token_endpoint'] ?? ''));
|
||||
$recommendationsMaxItems = (int) ($post['recommendations_max_items'] ?? 5);
|
||||
|
||||
$errorBag = formErrors();
|
||||
|
||||
@@ -65,11 +66,66 @@ if ($request->isMethod('POST')) {
|
||||
$settingsGateway->setOAuthTokenEndpoint($oauthTokenEndpoint);
|
||||
}
|
||||
|
||||
$settingsGateway->setSystemRecommendationsConfig([
|
||||
'version' => 1,
|
||||
'max_items' => $recommendationsMaxItems,
|
||||
'rules' => [
|
||||
'escalation_overdue' => [
|
||||
'enabled' => isset($post['recommendations_escalation_overdue_enabled']),
|
||||
'priority' => (int) ($post['recommendations_escalation_overdue_priority'] ?? 100),
|
||||
'min_overdue_minutes' => (int) ($post['recommendations_escalation_overdue_min_overdue_minutes'] ?? 0),
|
||||
],
|
||||
'high_risk_aging' => [
|
||||
'enabled' => isset($post['recommendations_high_risk_aging_enabled']),
|
||||
'priority' => (int) ($post['recommendations_high_risk_aging_priority'] ?? 90),
|
||||
'codes' => trim((string) ($post['recommendations_high_risk_aging_codes'] ?? 'MAX,HOCH')),
|
||||
'min_age_hours' => (int) ($post['recommendations_high_risk_aging_min_age_hours'] ?? 8),
|
||||
],
|
||||
'unassigned_open' => [
|
||||
'enabled' => isset($post['recommendations_unassigned_open_enabled']),
|
||||
'priority' => (int) ($post['recommendations_unassigned_open_priority'] ?? 80),
|
||||
'min_age_hours' => (int) ($post['recommendations_unassigned_open_min_age_hours'] ?? 2),
|
||||
],
|
||||
'stale_open' => [
|
||||
'enabled' => isset($post['recommendations_stale_open_enabled']),
|
||||
'priority' => (int) ($post['recommendations_stale_open_priority'] ?? 70),
|
||||
'min_age_hours' => (int) ($post['recommendations_stale_open_min_age_hours'] ?? 48),
|
||||
],
|
||||
'customer_backlog' => [
|
||||
'enabled' => isset($post['recommendations_customer_backlog_enabled']),
|
||||
'priority' => (int) ($post['recommendations_customer_backlog_priority'] ?? 60),
|
||||
'min_open_tickets' => (int) ($post['recommendations_customer_backlog_min_open_tickets'] ?? 6),
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
$settingsGateway->setControllingRiskConfig([
|
||||
'version' => 1,
|
||||
'rules' => [
|
||||
'ticket_volume_increase' => [
|
||||
'enabled' => isset($post['controlling_ticket_volume_increase_enabled']),
|
||||
'threshold_percent' => (int) ($post['controlling_ticket_volume_increase_threshold'] ?? 30),
|
||||
],
|
||||
'avg_resolution_high' => [
|
||||
'enabled' => isset($post['controlling_avg_resolution_high_enabled']),
|
||||
'threshold_hours' => (int) ($post['controlling_avg_resolution_high_threshold'] ?? 72),
|
||||
],
|
||||
'open_aging' => [
|
||||
'enabled' => isset($post['controlling_open_aging_enabled']),
|
||||
'threshold_hours' => (int) ($post['controlling_open_aging_threshold'] ?? 168),
|
||||
],
|
||||
'unassigned_ratio' => [
|
||||
'enabled' => isset($post['controlling_unassigned_ratio_enabled']),
|
||||
'threshold_percent' => (int) ($post['controlling_unassigned_ratio_threshold'] ?? 20),
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
$action = trim((string) ($post['action'] ?? 'save'));
|
||||
Flash::success(t('Settings saved'), 'helpdesk/settings', 'settings_saved');
|
||||
|
||||
if ($action === 'save_close') {
|
||||
Router::redirect('helpdesk/debitor');
|
||||
Router::redirect('helpdesk');
|
||||
|
||||
return;
|
||||
}
|
||||
@@ -93,6 +149,17 @@ $oauthClientId = $settingsGateway->getOAuthClientId() ?? '';
|
||||
$hasOAuthClientSecret = $settingsGateway->getOAuthClientSecret() !== null;
|
||||
$oauthTokenEndpoint = $settingsGateway->getOAuthTokenEndpoint() ?? '';
|
||||
$configErrors = $settingsGateway->validateConfiguration();
|
||||
$recommendationsConfigEnvelope = $settingsGateway->getSystemRecommendationsConfigEnvelope();
|
||||
$recommendationsConfig = is_array($recommendationsConfigEnvelope['config'] ?? null)
|
||||
? $recommendationsConfigEnvelope['config']
|
||||
: [];
|
||||
$recommendationsConfigSource = (string) ($recommendationsConfigEnvelope['source'] ?? 'default');
|
||||
|
||||
$controllingConfigEnvelope = $settingsGateway->getControllingRiskConfigEnvelope();
|
||||
$controllingConfig = is_array($controllingConfigEnvelope['config'] ?? null)
|
||||
? $controllingConfigEnvelope['config']
|
||||
: [];
|
||||
$controllingConfigSource = (string) ($controllingConfigEnvelope['source'] ?? 'default');
|
||||
|
||||
Buffer::set('title', t('Helpdesk settings'));
|
||||
Buffer::set('style_groups', json_encode(['helpdesk']));
|
||||
|
||||
@@ -11,6 +11,10 @@
|
||||
* @var bool $hasOAuthClientSecret
|
||||
* @var string $oauthTokenEndpoint
|
||||
* @var array $configErrors
|
||||
* @var array $recommendationsConfig
|
||||
* @var string $recommendationsConfigSource
|
||||
* @var array $controllingConfig
|
||||
* @var string $controllingConfigSource
|
||||
*/
|
||||
|
||||
use MintyPHP\Module\Helpdesk\Service\HelpdeskSettingsGateway;
|
||||
@@ -26,23 +30,38 @@ $oauthClientId = $oauthClientId ?? '';
|
||||
$hasOAuthClientSecret = $hasOAuthClientSecret ?? false;
|
||||
$oauthTokenEndpoint = $oauthTokenEndpoint ?? '';
|
||||
$configErrors = is_array($configErrors ?? null) ? $configErrors : [];
|
||||
|
||||
$recommendationsConfig = is_array($recommendationsConfig ?? null) ? $recommendationsConfig : [];
|
||||
$recommendationsConfigSource = (string) ($recommendationsConfigSource ?? 'default');
|
||||
$recommendationsMaxItems = (int) ($recommendationsConfig['max_items'] ?? 5);
|
||||
$recommendationRules = is_array($recommendationsConfig['rules'] ?? null) ? $recommendationsConfig['rules'] : [];
|
||||
$ruleEscalationOverdue = is_array($recommendationRules['escalation_overdue'] ?? null) ? $recommendationRules['escalation_overdue'] : [];
|
||||
$ruleHighRiskAging = is_array($recommendationRules['high_risk_aging'] ?? null) ? $recommendationRules['high_risk_aging'] : [];
|
||||
$ruleUnassignedOpen = is_array($recommendationRules['unassigned_open'] ?? null) ? $recommendationRules['unassigned_open'] : [];
|
||||
$ruleStaleOpen = is_array($recommendationRules['stale_open'] ?? null) ? $recommendationRules['stale_open'] : [];
|
||||
$ruleCustomerBacklog = is_array($recommendationRules['customer_backlog'] ?? null) ? $recommendationRules['customer_backlog'] : [];
|
||||
$highRiskCodes = is_array($ruleHighRiskAging['codes'] ?? null) ? implode(',', $ruleHighRiskAging['codes']) : 'MAX,HOCH';
|
||||
$controllingConfig = is_array($controllingConfig ?? null) ? $controllingConfig : [];
|
||||
$controllingConfigSource = (string) ($controllingConfigSource ?? 'default');
|
||||
$controllingRules = is_array($controllingConfig['rules'] ?? null) ? $controllingConfig['rules'] : [];
|
||||
$ctrlTicketVolume = is_array($controllingRules['ticket_volume_increase'] ?? null) ? $controllingRules['ticket_volume_increase'] : [];
|
||||
$ctrlAvgResolution = is_array($controllingRules['avg_resolution_high'] ?? null) ? $controllingRules['avg_resolution_high'] : [];
|
||||
$ctrlOpenAging = is_array($controllingRules['open_aging'] ?? null) ? $controllingRules['open_aging'] : [];
|
||||
$ctrlUnassigned = is_array($controllingRules['unassigned_ratio'] ?? null) ? $controllingRules['unassigned_ratio'] : [];
|
||||
$isOAuth2 = $authMode === HelpdeskSettingsGateway::AUTH_MODE_OAUTH2;
|
||||
?>
|
||||
<div class="app-details-container">
|
||||
<section>
|
||||
<?php
|
||||
$breadcrumbs = [
|
||||
['label' => t('Home'), 'path' => 'admin'],
|
||||
['label' => t('Helpdesk'), 'path' => 'helpdesk/debitor'],
|
||||
['label' => t('Helpdesk'), 'path' => 'helpdesk'],
|
||||
['label' => t('Settings')],
|
||||
];
|
||||
require templatePath('partials/app-breadcrumb.phtml');
|
||||
?>
|
||||
|
||||
<?php
|
||||
$titlebar = [
|
||||
'title' => t('Helpdesk settings'),
|
||||
'backHref' => lurl('helpdesk/debitor'),
|
||||
'backHref' => lurl('helpdesk'),
|
||||
'actions' => [
|
||||
[
|
||||
'label' => t('Save'),
|
||||
@@ -50,7 +69,9 @@ $configErrors = is_array($configErrors ?? null) ? $configErrors : [];
|
||||
'form' => 'helpdesk-settings-form',
|
||||
'name' => 'action',
|
||||
'value' => 'save',
|
||||
'class' => 'secondary outline',
|
||||
'class' => 'primary',
|
||||
'detailSavePrimary' => true,
|
||||
'detailActionKind' => 'save',
|
||||
],
|
||||
[
|
||||
'label' => t('Save & close'),
|
||||
@@ -58,15 +79,14 @@ $configErrors = is_array($configErrors ?? null) ? $configErrors : [];
|
||||
'form' => 'helpdesk-settings-form',
|
||||
'name' => 'action',
|
||||
'value' => 'save_close',
|
||||
'class' => 'primary',
|
||||
'class' => 'secondary outline',
|
||||
],
|
||||
],
|
||||
];
|
||||
require templatePath('partials/app-details-titlebar.phtml');
|
||||
require templatePath('partials/app-flash.phtml');
|
||||
?>
|
||||
|
||||
<?php require templatePath('partials/app-flash.phtml'); ?>
|
||||
|
||||
<?php if ($configErrors !== []): ?>
|
||||
<div class="notice" data-variant="warning" role="alert">
|
||||
<strong><?php e(t('Configuration incomplete')); ?>:</strong>
|
||||
@@ -78,118 +98,388 @@ $configErrors = is_array($configErrors ?? null) ? $configErrors : [];
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<form id="helpdesk-settings-form" method="post" action="<?php e(lurl('helpdesk/settings')); ?>">
|
||||
<?php if ($recommendationsConfigSource === 'fallback_invalid_json'): ?>
|
||||
<div class="notice" data-variant="warning" role="alert">
|
||||
<p><?php e(t('System recommendation configuration was invalid. Defaults were applied.')); ?></p>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if ($controllingConfigSource === 'fallback_invalid_json'): ?>
|
||||
<div class="notice" data-variant="warning" role="alert">
|
||||
<p><?php e(t('Controlling risk configuration was invalid. Defaults were applied.')); ?></p>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<form
|
||||
id="helpdesk-settings-form"
|
||||
method="post"
|
||||
action="<?php e(lurl('helpdesk/settings')); ?>"
|
||||
data-standard-detail-form="1"
|
||||
data-details-storage="helpdesk-settings-main-details-v1"
|
||||
data-test-success-message="<?php e(t('Connection successful')); ?>"
|
||||
data-test-error-message="<?php e(t('Connection failed')); ?>"
|
||||
data-test-loading-label="<?php e(t('Testing...')); ?>"
|
||||
data-test-default-label="<?php e(t('Test connection')); ?>"
|
||||
>
|
||||
<?php Session::getCsrfInput(); ?>
|
||||
|
||||
<fieldset>
|
||||
<legend><?php e(t('OData connection')); ?></legend>
|
||||
<div class="app-tabs" data-tabs data-app-component="tabs" data-tabs-param="tab" data-tabs-storage-key="helpdesk-settings-tabs-v1">
|
||||
<div class="app-tabs-nav">
|
||||
<button type="button" data-tab="connection" data-tab-default><?php e(t('Connection')); ?></button>
|
||||
<button type="button" data-tab="auth"><?php e(t('Authentication')); ?></button>
|
||||
<button type="button" data-tab="recommendations"><?php e(t('System recommendations')); ?></button>
|
||||
<button type="button" data-tab="controlling"><?php e(t('Controlling')); ?></button>
|
||||
</div>
|
||||
|
||||
<label for="odata_base_url"><?php e(t('OData Base URL')); ?></label>
|
||||
<input
|
||||
type="url"
|
||||
id="odata_base_url"
|
||||
name="odata_base_url"
|
||||
value="<?php e($odataBaseUrl); ?>"
|
||||
placeholder="https://bc.example.com:7048/BusinessCentral/ODataV4"
|
||||
>
|
||||
<small><?php e(t('Base URL without Company path segment')); ?></small>
|
||||
<div data-tab-panel="connection" class="helpdesk-settings-panel">
|
||||
<details class="app-details-card" name="helpdesk-odata" data-details-key="helpdesk-settings-odata" open>
|
||||
<summary>
|
||||
<span class="app-details-card-summary-title"><?php e(t('OData connection')); ?></span>
|
||||
</summary>
|
||||
<div class="app-details-card-container">
|
||||
<label class="app-field" for="odata_base_url">
|
||||
<span><?php e(t('OData Base URL')); ?></span>
|
||||
<input
|
||||
type="url"
|
||||
id="odata_base_url"
|
||||
name="odata_base_url"
|
||||
value="<?php e($odataBaseUrl); ?>"
|
||||
placeholder="https://bc.example.com:7048/BusinessCentral/ODataV4"
|
||||
>
|
||||
<small><?php e(t('Base URL without Company path segment')); ?></small>
|
||||
</label>
|
||||
|
||||
<label for="company_name"><?php e(t('Company name')); ?></label>
|
||||
<input
|
||||
type="text"
|
||||
id="company_name"
|
||||
name="company_name"
|
||||
value="<?php e($companyName); ?>"
|
||||
placeholder="<?php e(HelpdeskSettingsGateway::DEFAULT_COMPANY_NAME); ?>"
|
||||
>
|
||||
<small><?php e(t('BC company name (URL-encoded automatically)')); ?></small>
|
||||
</fieldset>
|
||||
<label class="app-field" for="company_name">
|
||||
<span><?php e(t('Company name')); ?></span>
|
||||
<input
|
||||
type="text"
|
||||
id="company_name"
|
||||
name="company_name"
|
||||
value="<?php e($companyName); ?>"
|
||||
placeholder="<?php e(HelpdeskSettingsGateway::DEFAULT_COMPANY_NAME); ?>"
|
||||
>
|
||||
<small><?php e(t('BC company name (URL-encoded automatically)')); ?></small>
|
||||
</label>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<fieldset>
|
||||
<legend><?php e(t('Authentication')); ?></legend>
|
||||
<details class="app-details-card" name="helpdesk-connection-test" data-details-key="helpdesk-settings-connection-test" open>
|
||||
<summary>
|
||||
<span class="app-details-card-summary-title"><?php e(t('Connection test')); ?></span>
|
||||
</summary>
|
||||
<div class="app-details-card-container">
|
||||
<button
|
||||
type="button"
|
||||
id="helpdesk-test-connection-button"
|
||||
class="secondary outline"
|
||||
data-label-default="<?php e(t('Test connection')); ?>"
|
||||
data-label-loading="<?php e(t('Testing...')); ?>"
|
||||
>
|
||||
<?php e(t('Test connection')); ?>
|
||||
</button>
|
||||
<div id="helpdesk-connection-test-result" class="notice helpdesk-settings-test-result" data-variant="info" role="status" aria-live="polite" hidden></div>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
|
||||
<label for="auth_mode"><?php e(t('Auth mode')); ?></label>
|
||||
<select id="auth_mode" name="auth_mode">
|
||||
<option value="<?php e(HelpdeskSettingsGateway::AUTH_MODE_BASIC); ?>" <?php if ($authMode === HelpdeskSettingsGateway::AUTH_MODE_BASIC): ?>selected<?php endif; ?>>
|
||||
<?php e(t('Basic Auth')); ?>
|
||||
</option>
|
||||
<option value="<?php e(HelpdeskSettingsGateway::AUTH_MODE_OAUTH2); ?>" <?php if ($authMode === HelpdeskSettingsGateway::AUTH_MODE_OAUTH2): ?>selected<?php endif; ?>>
|
||||
<?php e(t('OAuth2 (client credentials)')); ?>
|
||||
</option>
|
||||
</select>
|
||||
</fieldset>
|
||||
<div data-tab-panel="auth" class="helpdesk-settings-panel">
|
||||
<details class="app-details-card" name="helpdesk-auth-mode" data-details-key="helpdesk-settings-auth-mode" open>
|
||||
<summary>
|
||||
<span class="app-details-card-summary-title"><?php e(t('Authentication')); ?></span>
|
||||
</summary>
|
||||
<div class="app-details-card-container">
|
||||
<label class="app-field" for="auth_mode">
|
||||
<span><?php e(t('Auth mode')); ?></span>
|
||||
<select id="auth_mode" name="auth_mode">
|
||||
<option value="<?php e(HelpdeskSettingsGateway::AUTH_MODE_BASIC); ?>" <?php if ($authMode === HelpdeskSettingsGateway::AUTH_MODE_BASIC): ?>selected<?php endif; ?>>
|
||||
<?php e(t('Basic Auth')); ?>
|
||||
</option>
|
||||
<option value="<?php e(HelpdeskSettingsGateway::AUTH_MODE_OAUTH2); ?>" <?php if ($authMode === HelpdeskSettingsGateway::AUTH_MODE_OAUTH2): ?>selected<?php endif; ?>>
|
||||
<?php e(t('OAuth2 (client credentials)')); ?>
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<fieldset id="helpdesk-basic-auth-fields">
|
||||
<legend><?php e(t('Basic Auth credentials')); ?></legend>
|
||||
<details id="helpdesk-basic-auth-fields" class="app-details-card" name="helpdesk-basic-auth" data-details-key="helpdesk-settings-basic-auth" <?php if ($isOAuth2): ?>hidden<?php else: ?>open<?php endif; ?>>
|
||||
<summary>
|
||||
<span class="app-details-card-summary-title"><?php e(t('Basic Auth credentials')); ?></span>
|
||||
</summary>
|
||||
<div class="app-details-card-container helpdesk-settings-rule-grid">
|
||||
<label class="app-field" for="basic_user">
|
||||
<span><?php e(t('Username')); ?></span>
|
||||
<input
|
||||
type="text"
|
||||
id="basic_user"
|
||||
name="basic_user"
|
||||
value="<?php e($basicUser); ?>"
|
||||
autocomplete="off"
|
||||
>
|
||||
</label>
|
||||
|
||||
<label for="basic_user"><?php e(t('Username')); ?></label>
|
||||
<input
|
||||
type="text"
|
||||
id="basic_user"
|
||||
name="basic_user"
|
||||
value="<?php e($basicUser); ?>"
|
||||
autocomplete="off"
|
||||
>
|
||||
<label class="app-field" for="basic_password">
|
||||
<span><?php e(t('Password')); ?></span>
|
||||
<input
|
||||
type="password"
|
||||
id="basic_password"
|
||||
name="basic_password"
|
||||
value="<?php if ($hasBasicPassword): ?>********<?php endif; ?>"
|
||||
autocomplete="new-password"
|
||||
>
|
||||
<?php if ($hasBasicPassword): ?>
|
||||
<small><?php e(t('Leave unchanged to keep current password')); ?></small>
|
||||
<?php endif; ?>
|
||||
</label>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<label for="basic_password"><?php e(t('Password')); ?></label>
|
||||
<input
|
||||
type="password"
|
||||
id="basic_password"
|
||||
name="basic_password"
|
||||
value="<?php if ($hasBasicPassword): ?>********<?php endif; ?>"
|
||||
autocomplete="new-password"
|
||||
>
|
||||
<?php if ($hasBasicPassword): ?>
|
||||
<small><?php e(t('Leave unchanged to keep current password')); ?></small>
|
||||
<?php endif; ?>
|
||||
</fieldset>
|
||||
<details id="helpdesk-oauth2-fields" class="app-details-card" name="helpdesk-oauth2-auth" data-details-key="helpdesk-settings-oauth2-auth" <?php if (!$isOAuth2): ?>hidden<?php else: ?>open<?php endif; ?>>
|
||||
<summary>
|
||||
<span class="app-details-card-summary-title"><?php e(t('OAuth2 credentials')); ?></span>
|
||||
</summary>
|
||||
<div class="app-details-card-container helpdesk-settings-rule-grid">
|
||||
<label class="app-field" for="oauth_tenant_id">
|
||||
<span><?php e(t('Azure Tenant ID')); ?></span>
|
||||
<input
|
||||
type="text"
|
||||
id="oauth_tenant_id"
|
||||
name="oauth_tenant_id"
|
||||
value="<?php e($oauthTenantId); ?>"
|
||||
autocomplete="off"
|
||||
>
|
||||
</label>
|
||||
|
||||
<fieldset id="helpdesk-oauth2-fields">
|
||||
<legend><?php e(t('OAuth2 credentials')); ?></legend>
|
||||
<label class="app-field" for="oauth_client_id">
|
||||
<span><?php e(t('Client ID')); ?></span>
|
||||
<input
|
||||
type="text"
|
||||
id="oauth_client_id"
|
||||
name="oauth_client_id"
|
||||
value="<?php e($oauthClientId); ?>"
|
||||
autocomplete="off"
|
||||
>
|
||||
</label>
|
||||
|
||||
<label for="oauth_tenant_id"><?php e(t('Azure Tenant ID')); ?></label>
|
||||
<input
|
||||
type="text"
|
||||
id="oauth_tenant_id"
|
||||
name="oauth_tenant_id"
|
||||
value="<?php e($oauthTenantId); ?>"
|
||||
autocomplete="off"
|
||||
>
|
||||
<label class="app-field" for="oauth_client_secret">
|
||||
<span><?php e(t('Client Secret')); ?></span>
|
||||
<input
|
||||
type="password"
|
||||
id="oauth_client_secret"
|
||||
name="oauth_client_secret"
|
||||
value="<?php if ($hasOAuthClientSecret): ?>********<?php endif; ?>"
|
||||
autocomplete="new-password"
|
||||
>
|
||||
<?php if ($hasOAuthClientSecret): ?>
|
||||
<small><?php e(t('Leave unchanged to keep current password')); ?></small>
|
||||
<?php endif; ?>
|
||||
</label>
|
||||
|
||||
<label for="oauth_client_id"><?php e(t('Client ID')); ?></label>
|
||||
<input
|
||||
type="text"
|
||||
id="oauth_client_id"
|
||||
name="oauth_client_id"
|
||||
value="<?php e($oauthClientId); ?>"
|
||||
autocomplete="off"
|
||||
>
|
||||
<label class="app-field" for="oauth_token_endpoint">
|
||||
<span><?php e(t('Token endpoint URL')); ?></span>
|
||||
<input
|
||||
type="url"
|
||||
id="oauth_token_endpoint"
|
||||
name="oauth_token_endpoint"
|
||||
value="<?php e($oauthTokenEndpoint); ?>"
|
||||
placeholder="https://login.microsoftonline.com/{tenant}/oauth2/v2.0/token"
|
||||
>
|
||||
</label>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
|
||||
<label for="oauth_client_secret"><?php e(t('Client Secret')); ?></label>
|
||||
<input
|
||||
type="password"
|
||||
id="oauth_client_secret"
|
||||
name="oauth_client_secret"
|
||||
value="<?php if ($hasOAuthClientSecret): ?>********<?php endif; ?>"
|
||||
autocomplete="new-password"
|
||||
>
|
||||
<div data-tab-panel="recommendations" class="helpdesk-settings-panel">
|
||||
<details class="app-details-card" name="helpdesk-recommendations-limits" data-details-key="helpdesk-settings-recommendations-limits" open>
|
||||
<summary>
|
||||
<span class="app-details-card-summary-title"><?php e(t('Maximum recommendations')); ?></span>
|
||||
</summary>
|
||||
<div class="app-details-card-container">
|
||||
<label class="app-field" for="recommendations_max_items">
|
||||
<span><?php e(t('Maximum recommendations')); ?></span>
|
||||
<input
|
||||
type="number"
|
||||
id="recommendations_max_items"
|
||||
name="recommendations_max_items"
|
||||
min="1"
|
||||
max="20"
|
||||
value="<?php e($recommendationsMaxItems); ?>"
|
||||
>
|
||||
</label>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<label for="oauth_token_endpoint"><?php e(t('Token endpoint URL')); ?></label>
|
||||
<input
|
||||
type="url"
|
||||
id="oauth_token_endpoint"
|
||||
name="oauth_token_endpoint"
|
||||
value="<?php e($oauthTokenEndpoint); ?>"
|
||||
placeholder="https://login.microsoftonline.com/{tenant}/oauth2/v2.0/token"
|
||||
>
|
||||
</fieldset>
|
||||
<details class="app-details-card" name="helpdesk-rule-escalation-overdue" data-details-key="helpdesk-settings-rule-escalation-overdue" open>
|
||||
<summary>
|
||||
<span class="app-details-card-summary-title"><?php e(t('Escalation overdue')); ?></span>
|
||||
</summary>
|
||||
<div class="app-details-card-container helpdesk-settings-rule-grid">
|
||||
<label class="app-field">
|
||||
<input type="checkbox" role="switch" name="recommendations_escalation_overdue_enabled" <?php if (($ruleEscalationOverdue['enabled'] ?? true) === true): ?>checked<?php endif; ?>>
|
||||
<span><?php e(t('Enabled')); ?></span>
|
||||
</label>
|
||||
<label class="app-field">
|
||||
<span><?php e(t('Priority')); ?></span>
|
||||
<input type="number" name="recommendations_escalation_overdue_priority" min="1" max="999" value="<?php e((int) ($ruleEscalationOverdue['priority'] ?? 100)); ?>">
|
||||
</label>
|
||||
<label class="app-field helpdesk-settings-rule-grid-full">
|
||||
<span><?php e(t('Minimum overdue (minutes)')); ?></span>
|
||||
<input type="number" name="recommendations_escalation_overdue_min_overdue_minutes" min="0" max="43200" value="<?php e((int) ($ruleEscalationOverdue['min_overdue_minutes'] ?? 0)); ?>">
|
||||
</label>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<fieldset>
|
||||
<legend><?php e(t('Connection test')); ?></legend>
|
||||
<button type="button" id="helpdesk-test-connection-button" class="secondary outline">
|
||||
<?php e(t('Test connection')); ?>
|
||||
</button>
|
||||
</fieldset>
|
||||
<details class="app-details-card" name="helpdesk-rule-high-risk" data-details-key="helpdesk-settings-rule-high-risk" open>
|
||||
<summary>
|
||||
<span class="app-details-card-summary-title"><?php e(t('High-risk aging')); ?></span>
|
||||
</summary>
|
||||
<div class="app-details-card-container helpdesk-settings-rule-grid">
|
||||
<label class="app-field">
|
||||
<input type="checkbox" role="switch" name="recommendations_high_risk_aging_enabled" <?php if (($ruleHighRiskAging['enabled'] ?? true) === true): ?>checked<?php endif; ?>>
|
||||
<span><?php e(t('Enabled')); ?></span>
|
||||
</label>
|
||||
<label class="app-field">
|
||||
<span><?php e(t('Priority')); ?></span>
|
||||
<input type="number" name="recommendations_high_risk_aging_priority" min="1" max="999" value="<?php e((int) ($ruleHighRiskAging['priority'] ?? 90)); ?>">
|
||||
</label>
|
||||
<label class="app-field helpdesk-settings-rule-grid-full">
|
||||
<span><?php e(t('High-risk codes (comma separated)')); ?></span>
|
||||
<input type="text" name="recommendations_high_risk_aging_codes" value="<?php e($highRiskCodes); ?>">
|
||||
</label>
|
||||
<label class="app-field helpdesk-settings-rule-grid-full">
|
||||
<span><?php e(t('Minimum age (hours)')); ?></span>
|
||||
<input type="number" name="recommendations_high_risk_aging_min_age_hours" min="0" max="720" value="<?php e((int) ($ruleHighRiskAging['min_age_hours'] ?? 8)); ?>">
|
||||
</label>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<details class="app-details-card" name="helpdesk-rule-unassigned-open" data-details-key="helpdesk-settings-rule-unassigned-open" open>
|
||||
<summary>
|
||||
<span class="app-details-card-summary-title"><?php e(t('Unassigned open')); ?></span>
|
||||
</summary>
|
||||
<div class="app-details-card-container helpdesk-settings-rule-grid">
|
||||
<label class="app-field">
|
||||
<input type="checkbox" role="switch" name="recommendations_unassigned_open_enabled" <?php if (($ruleUnassignedOpen['enabled'] ?? true) === true): ?>checked<?php endif; ?>>
|
||||
<span><?php e(t('Enabled')); ?></span>
|
||||
</label>
|
||||
<label class="app-field">
|
||||
<span><?php e(t('Priority')); ?></span>
|
||||
<input type="number" name="recommendations_unassigned_open_priority" min="1" max="999" value="<?php e((int) ($ruleUnassignedOpen['priority'] ?? 80)); ?>">
|
||||
</label>
|
||||
<label class="app-field helpdesk-settings-rule-grid-full">
|
||||
<span><?php e(t('Minimum age (hours)')); ?></span>
|
||||
<input type="number" name="recommendations_unassigned_open_min_age_hours" min="0" max="720" value="<?php e((int) ($ruleUnassignedOpen['min_age_hours'] ?? 2)); ?>">
|
||||
</label>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<details class="app-details-card" name="helpdesk-rule-stale-open" data-details-key="helpdesk-settings-rule-stale-open" open>
|
||||
<summary>
|
||||
<span class="app-details-card-summary-title"><?php e(t('Stale open')); ?></span>
|
||||
</summary>
|
||||
<div class="app-details-card-container helpdesk-settings-rule-grid">
|
||||
<label class="app-field">
|
||||
<input type="checkbox" role="switch" name="recommendations_stale_open_enabled" <?php if (($ruleStaleOpen['enabled'] ?? true) === true): ?>checked<?php endif; ?>>
|
||||
<span><?php e(t('Enabled')); ?></span>
|
||||
</label>
|
||||
<label class="app-field">
|
||||
<span><?php e(t('Priority')); ?></span>
|
||||
<input type="number" name="recommendations_stale_open_priority" min="1" max="999" value="<?php e((int) ($ruleStaleOpen['priority'] ?? 70)); ?>">
|
||||
</label>
|
||||
<label class="app-field helpdesk-settings-rule-grid-full">
|
||||
<span><?php e(t('Minimum age (hours)')); ?></span>
|
||||
<input type="number" name="recommendations_stale_open_min_age_hours" min="0" max="1440" value="<?php e((int) ($ruleStaleOpen['min_age_hours'] ?? 48)); ?>">
|
||||
</label>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<details class="app-details-card" name="helpdesk-rule-customer-backlog" data-details-key="helpdesk-settings-rule-customer-backlog" open>
|
||||
<summary>
|
||||
<span class="app-details-card-summary-title"><?php e(t('Customer backlog')); ?></span>
|
||||
</summary>
|
||||
<div class="app-details-card-container helpdesk-settings-rule-grid">
|
||||
<label class="app-field">
|
||||
<input type="checkbox" role="switch" name="recommendations_customer_backlog_enabled" <?php if (($ruleCustomerBacklog['enabled'] ?? true) === true): ?>checked<?php endif; ?>>
|
||||
<span><?php e(t('Enabled')); ?></span>
|
||||
</label>
|
||||
<label class="app-field">
|
||||
<span><?php e(t('Priority')); ?></span>
|
||||
<input type="number" name="recommendations_customer_backlog_priority" min="1" max="999" value="<?php e((int) ($ruleCustomerBacklog['priority'] ?? 60)); ?>">
|
||||
</label>
|
||||
<label class="app-field helpdesk-settings-rule-grid-full">
|
||||
<span><?php e(t('Minimum open tickets')); ?></span>
|
||||
<input type="number" name="recommendations_customer_backlog_min_open_tickets" min="1" max="500" value="<?php e((int) ($ruleCustomerBacklog['min_open_tickets'] ?? 6)); ?>">
|
||||
</label>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
|
||||
<div data-tab-panel="controlling" class="helpdesk-settings-panel">
|
||||
<details class="app-details-card" name="helpdesk-ctrl-ticket-volume" data-details-key="helpdesk-settings-ctrl-ticket-volume" open>
|
||||
<summary>
|
||||
<span class="app-details-card-summary-title"><?php e(t('Ticket volume increase')); ?></span>
|
||||
</summary>
|
||||
<div class="app-details-card-container helpdesk-settings-rule-grid">
|
||||
<label class="app-field">
|
||||
<input type="checkbox" role="switch" name="controlling_ticket_volume_increase_enabled" <?php if (($ctrlTicketVolume['enabled'] ?? true) === true): ?>checked<?php endif; ?>>
|
||||
<span><?php e(t('Enabled')); ?></span>
|
||||
</label>
|
||||
<label class="app-field">
|
||||
<span><?php e(t('Threshold (%)')); ?></span>
|
||||
<input type="number" name="controlling_ticket_volume_increase_threshold" min="5" max="200" value="<?php e((int) ($ctrlTicketVolume['threshold_percent'] ?? 30)); ?>">
|
||||
</label>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<details class="app-details-card" name="helpdesk-ctrl-avg-resolution" data-details-key="helpdesk-settings-ctrl-avg-resolution" open>
|
||||
<summary>
|
||||
<span class="app-details-card-summary-title"><?php e(t('High avg. resolution time')); ?></span>
|
||||
</summary>
|
||||
<div class="app-details-card-container helpdesk-settings-rule-grid">
|
||||
<label class="app-field">
|
||||
<input type="checkbox" role="switch" name="controlling_avg_resolution_high_enabled" <?php if (($ctrlAvgResolution['enabled'] ?? true) === true): ?>checked<?php endif; ?>>
|
||||
<span><?php e(t('Enabled')); ?></span>
|
||||
</label>
|
||||
<label class="app-field">
|
||||
<span><?php e(t('Threshold (hours)')); ?></span>
|
||||
<input type="number" name="controlling_avg_resolution_high_threshold" min="1" max="720" value="<?php e((int) ($ctrlAvgResolution['threshold_hours'] ?? 72)); ?>">
|
||||
</label>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<details class="app-details-card" name="helpdesk-ctrl-open-aging" data-details-key="helpdesk-settings-ctrl-open-aging" open>
|
||||
<summary>
|
||||
<span class="app-details-card-summary-title"><?php e(t('Open ticket aging')); ?></span>
|
||||
</summary>
|
||||
<div class="app-details-card-container helpdesk-settings-rule-grid">
|
||||
<label class="app-field">
|
||||
<input type="checkbox" role="switch" name="controlling_open_aging_enabled" <?php if (($ctrlOpenAging['enabled'] ?? true) === true): ?>checked<?php endif; ?>>
|
||||
<span><?php e(t('Enabled')); ?></span>
|
||||
</label>
|
||||
<label class="app-field">
|
||||
<span><?php e(t('Threshold (hours)')); ?></span>
|
||||
<input type="number" name="controlling_open_aging_threshold" min="1" max="2160" value="<?php e((int) ($ctrlOpenAging['threshold_hours'] ?? 168)); ?>">
|
||||
</label>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<details class="app-details-card" name="helpdesk-ctrl-unassigned" data-details-key="helpdesk-settings-ctrl-unassigned" open>
|
||||
<summary>
|
||||
<span class="app-details-card-summary-title"><?php e(t('Unassigned ticket ratio')); ?></span>
|
||||
</summary>
|
||||
<div class="app-details-card-container helpdesk-settings-rule-grid">
|
||||
<label class="app-field">
|
||||
<input type="checkbox" role="switch" name="controlling_unassigned_ratio_enabled" <?php if (($ctrlUnassigned['enabled'] ?? true) === true): ?>checked<?php endif; ?>>
|
||||
<span><?php e(t('Enabled')); ?></span>
|
||||
</label>
|
||||
<label class="app-field">
|
||||
<span><?php e(t('Threshold (%)')); ?></span>
|
||||
<input type="number" name="controlling_unassigned_ratio_threshold" min="1" max="100" value="<?php e((int) ($ctrlUnassigned['threshold_percent'] ?? 20)); ?>">
|
||||
</label>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
@@ -15,7 +15,7 @@ $ticketNo = trim((string) ($id ?? ''));
|
||||
$fromCustomerNo = trim((string) $request->query('from', ''));
|
||||
|
||||
if ($ticketNo === '') {
|
||||
Router::redirect('helpdesk/debitor');
|
||||
Router::redirect('helpdesk');
|
||||
|
||||
return;
|
||||
}
|
||||
@@ -32,8 +32,8 @@ try {
|
||||
$connectionError = $connectionError ?? false;
|
||||
|
||||
if ($ticket === null && !$connectionError) {
|
||||
Flash::error(t('Ticket not found'), 'helpdesk/debitor', 'ticket_not_found');
|
||||
Router::redirect('helpdesk/debitor');
|
||||
Flash::error(t('Ticket not found'), 'helpdesk', 'ticket_not_found');
|
||||
Router::redirect('helpdesk');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@ $ticketDescription = $ticketDescription ?? '';
|
||||
$ticketLogUrl = $ticketLogUrl ?? '';
|
||||
$ticketCommunicationUrl = $ticketCommunicationUrl ?? '';
|
||||
|
||||
$backUrl = $ticketCustomerNo !== '' ? lurl('helpdesk/debitor/' . rawurlencode($ticketCustomerNo)) : lurl('helpdesk/debitor');
|
||||
$backUrl = $ticketCustomerNo !== '' ? lurl('helpdesk/debitor/' . rawurlencode($ticketCustomerNo)) : lurl('helpdesk');
|
||||
|
||||
?>
|
||||
<div class="app-details-container"
|
||||
@@ -32,7 +32,7 @@ $backUrl = $ticketCustomerNo !== '' ? lurl('helpdesk/debitor/' . rawurlencode($t
|
||||
<?php
|
||||
$breadcrumbs = [
|
||||
['label' => t('Home'), 'path' => 'admin'],
|
||||
['label' => t('Helpdesk'), 'path' => 'helpdesk/debitor'],
|
||||
['label' => t('Helpdesk'), 'path' => 'helpdesk'],
|
||||
];
|
||||
if ($ticketCustomerNo !== '') {
|
||||
$breadcrumbs[] = ['label' => (string) ($ticket['Company_Contact_Name'] ?? $ticketCustomerNo), 'path' => 'helpdesk/debitor/' . rawurlencode($ticketCustomerNo)];
|
||||
|
||||
@@ -7,8 +7,8 @@ $canManageSettings = !empty($helpdeskNav['can_manage_settings']);
|
||||
$helpdeskNavItems = [
|
||||
[
|
||||
'label' => t('Customers'),
|
||||
'path' => 'helpdesk/debitor',
|
||||
'active' => navActive('helpdesk/debitor', true),
|
||||
'path' => 'helpdesk',
|
||||
'active' => navActive('helpdesk', true),
|
||||
'visible' => true,
|
||||
],
|
||||
[
|
||||
|
||||
@@ -10,7 +10,7 @@ use PHPUnit\Framework\TestCase;
|
||||
|
||||
class BcODataGatewayTest extends TestCase
|
||||
{
|
||||
private function createGatewayWithSettings(bool $isConfigured = true): BcODataGateway
|
||||
private function createGatewayWithSettings(bool $isConfigured = true, ?SessionStoreInterface $sessionStore = null): BcODataGateway
|
||||
{
|
||||
$settings = $this->createMock(HelpdeskSettingsGateway::class);
|
||||
$settings->method('isConfigured')->willReturn($isConfigured);
|
||||
@@ -24,10 +24,12 @@ class BcODataGatewayTest extends TestCase
|
||||
$settings->method('getBasicPassword')->willReturn('testpass');
|
||||
|
||||
$oauthTokenService = $this->createMock(HelpdeskOAuthTokenService::class);
|
||||
$sessionStore = $this->createMock(SessionStoreInterface::class);
|
||||
$sessionStore->method('all')->willReturn([
|
||||
'current_tenant' => ['id' => 1],
|
||||
]);
|
||||
if ($sessionStore === null) {
|
||||
$sessionStore = $this->createMock(SessionStoreInterface::class);
|
||||
$sessionStore->method('all')->willReturn([
|
||||
'current_tenant' => ['id' => 1],
|
||||
]);
|
||||
}
|
||||
|
||||
return new BcODataGateway($settings, $oauthTokenService, $sessionStore);
|
||||
}
|
||||
@@ -74,6 +76,20 @@ class BcODataGatewayTest extends TestCase
|
||||
$this->assertNull($result);
|
||||
}
|
||||
|
||||
public function testGetContractsForCustomerReturnsEmptyForEmptyNo(): void
|
||||
{
|
||||
$gateway = $this->createGatewayWithSettings();
|
||||
$results = $gateway->getContractsForCustomer('');
|
||||
$this->assertSame([], $results);
|
||||
}
|
||||
|
||||
public function testGetEscalationTicketsForCustomerReturnsEmptyForEmptyNo(): void
|
||||
{
|
||||
$gateway = $this->createGatewayWithSettings();
|
||||
$results = $gateway->getEscalationTicketsForCustomer('');
|
||||
$this->assertSame([], $results);
|
||||
}
|
||||
|
||||
public function testSearchCustomersThrowsWhenNotConfigured(): void
|
||||
{
|
||||
$gateway = $this->createGatewayWithSettings(false);
|
||||
@@ -95,6 +111,48 @@ class BcODataGatewayTest extends TestCase
|
||||
$this->assertNull($result);
|
||||
}
|
||||
|
||||
public function testGetContractsForCustomerReturnsEmptyWhenNotConfigured(): void
|
||||
{
|
||||
$gateway = $this->createGatewayWithSettings(false);
|
||||
$results = $gateway->getContractsForCustomer('10610');
|
||||
$this->assertSame([], $results);
|
||||
}
|
||||
|
||||
public function testGetEscalationDefinitionsReturnsEmptyWhenNotConfigured(): void
|
||||
{
|
||||
$gateway = $this->createGatewayWithSettings(false);
|
||||
$results = $gateway->getEscalationDefinitions();
|
||||
$this->assertSame([], $results);
|
||||
}
|
||||
|
||||
public function testGetEscalationDefinitionsReturnsCachedValuesWhenAvailable(): void
|
||||
{
|
||||
$sessionStore = $this->createMock(SessionStoreInterface::class);
|
||||
$sessionStore->method('all')->willReturn([
|
||||
'current_tenant' => ['id' => 7],
|
||||
]);
|
||||
$sessionStore->method('get')->with('module.helpdesk.debitor.v1.escalation-definitions.7')->willReturn([
|
||||
'values' => [
|
||||
['Code' => 'MAX', 'Target_Time' => 'P1DT0H0M0.0S'],
|
||||
],
|
||||
'fetched_at' => time(),
|
||||
]);
|
||||
$sessionStore->expects($this->never())->method('set');
|
||||
|
||||
$gateway = $this->createGatewayWithSettings(true, $sessionStore);
|
||||
$results = $gateway->getEscalationDefinitions();
|
||||
|
||||
$this->assertCount(1, $results);
|
||||
$this->assertSame('MAX', $results[0]['Code']);
|
||||
}
|
||||
|
||||
public function testGetEscalationTicketsForCustomerReturnsEmptyWhenNotConfigured(): void
|
||||
{
|
||||
$gateway = $this->createGatewayWithSettings(false);
|
||||
$results = $gateway->getEscalationTicketsForCustomer('10610');
|
||||
$this->assertSame([], $results);
|
||||
}
|
||||
|
||||
public function testTestConnectionReturnsNotOkWhenNotConfigured(): void
|
||||
{
|
||||
$gateway = $this->createGatewayWithSettings(false);
|
||||
@@ -193,4 +251,39 @@ class BcODataGatewayTest extends TestCase
|
||||
$this->expectException(\InvalidArgumentException::class);
|
||||
$gateway->listCustomers('', 'Berlin)', 10, 0, 'Name', 'asc');
|
||||
}
|
||||
|
||||
public function testGetContractLinesForCustomerReturnsEmptyForEmptyNo(): void
|
||||
{
|
||||
$gateway = $this->createGatewayWithSettings();
|
||||
$results = $gateway->getContractLinesForCustomer('');
|
||||
$this->assertSame([], $results);
|
||||
}
|
||||
|
||||
public function testGetContractLinesForCustomerReturnsEmptyForWhitespace(): void
|
||||
{
|
||||
$gateway = $this->createGatewayWithSettings();
|
||||
$results = $gateway->getContractLinesForCustomer(' ');
|
||||
$this->assertSame([], $results);
|
||||
}
|
||||
|
||||
public function testGetTicketsForControllingReturnsEmptyForBlankCustomerNo(): void
|
||||
{
|
||||
$gateway = $this->createGatewayWithSettings();
|
||||
$results = $gateway->getTicketsForControlling('');
|
||||
$this->assertSame([], $results);
|
||||
}
|
||||
|
||||
public function testGetTicketsForControllingReturnsEmptyForWhitespace(): void
|
||||
{
|
||||
$gateway = $this->createGatewayWithSettings();
|
||||
$results = $gateway->getTicketsForControlling(' ');
|
||||
$this->assertSame([], $results);
|
||||
}
|
||||
|
||||
public function testGetTicketsForControllingReturnsEmptyWhenNotConfigured(): void
|
||||
{
|
||||
$gateway = $this->createGatewayWithSettings(false);
|
||||
$results = $gateway->getTicketsForControlling('10610');
|
||||
$this->assertSame([], $results);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Tests\Module\Helpdesk\Service;
|
||||
|
||||
use MintyPHP\Http\SessionStoreInterface;
|
||||
use MintyPHP\Module\Helpdesk\Service\DebitorCacheControl;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
class DebitorCacheControlTest extends TestCase
|
||||
{
|
||||
public function testParseRefreshFlagRecognizesTruthyValues(): void
|
||||
{
|
||||
$this->assertTrue(DebitorCacheControl::parseRefreshFlag('1'));
|
||||
$this->assertTrue(DebitorCacheControl::parseRefreshFlag('true'));
|
||||
$this->assertTrue(DebitorCacheControl::parseRefreshFlag('YES'));
|
||||
$this->assertTrue(DebitorCacheControl::parseRefreshFlag(true));
|
||||
}
|
||||
|
||||
public function testParseRefreshFlagReturnsFalseForNonTruthyValues(): void
|
||||
{
|
||||
$this->assertFalse(DebitorCacheControl::parseRefreshFlag(''));
|
||||
$this->assertFalse(DebitorCacheControl::parseRefreshFlag('0'));
|
||||
$this->assertFalse(DebitorCacheControl::parseRefreshFlag('false'));
|
||||
$this->assertFalse(DebitorCacheControl::parseRefreshFlag('nope'));
|
||||
$this->assertFalse(DebitorCacheControl::parseRefreshFlag(false));
|
||||
$this->assertFalse(DebitorCacheControl::parseRefreshFlag(null));
|
||||
}
|
||||
|
||||
public function testResolveTenantScopeUsesTenantIdOrGlobal(): void
|
||||
{
|
||||
$this->assertSame('9', DebitorCacheControl::resolveTenantScope([
|
||||
'current_tenant' => ['id' => 9],
|
||||
]));
|
||||
|
||||
$this->assertSame('global', DebitorCacheControl::resolveTenantScope([]));
|
||||
}
|
||||
|
||||
public function testInvalidateDebitorCachesRemovesDebitorAndTenantSharedKeys(): void
|
||||
{
|
||||
$sessionStore = $this->createMock(SessionStoreInterface::class);
|
||||
$sessionStore->expects($this->exactly(13))
|
||||
->method('remove')
|
||||
->with($this->callback(static function (string $key): bool {
|
||||
return str_contains($key, '.13.10259')
|
||||
|| $key === 'module.helpdesk.debitor.v1.escalation-definitions.13';
|
||||
}));
|
||||
|
||||
DebitorCacheControl::invalidateDebitorCaches($sessionStore, '13', '10259', true);
|
||||
}
|
||||
|
||||
public function testContractLinesKeyFormat(): void
|
||||
{
|
||||
$key = DebitorCacheControl::contractLinesKey('5', '10254');
|
||||
$this->assertSame('module.helpdesk.debitor.v2.contract-lines.5.10254', $key);
|
||||
}
|
||||
|
||||
public function testAllDebitorCacheKeysIncludesContractLinesKey(): void
|
||||
{
|
||||
$keys = DebitorCacheControl::allDebitorCacheKeys('5', '10254');
|
||||
$this->assertContains('module.helpdesk.debitor.v2.contract-lines.5.10254', $keys);
|
||||
}
|
||||
|
||||
public function testControllingTicketsKeyFormat(): void
|
||||
{
|
||||
$key = DebitorCacheControl::controllingTicketsKey('7', '10610');
|
||||
$this->assertSame('module.helpdesk.debitor.v2.controlling-tickets.7.10610', $key);
|
||||
}
|
||||
|
||||
public function testAllDebitorCacheKeysIncludesControllingKey(): void
|
||||
{
|
||||
$keys = DebitorCacheControl::allDebitorCacheKeys('7', '10610');
|
||||
$this->assertContains('module.helpdesk.debitor.v2.controlling-tickets.7.10610', $keys);
|
||||
}
|
||||
}
|
||||
@@ -260,6 +260,86 @@ class DebitorDetailServiceTest extends TestCase
|
||||
$this->assertSame('Timeout', $result['error']);
|
||||
}
|
||||
|
||||
// --- loadContracts() ---
|
||||
|
||||
public function testLoadContractsReturnsFalseForEmptyCustomerNo(): void
|
||||
{
|
||||
$service = $this->createService();
|
||||
$result = $service->loadContracts('');
|
||||
$this->assertFalse($result['ok']);
|
||||
}
|
||||
|
||||
public function testLoadContractsReturnsFalseWhenNotConfigured(): void
|
||||
{
|
||||
$service = $this->createService(null, false);
|
||||
$result = $service->loadContracts('10610');
|
||||
$this->assertFalse($result['ok']);
|
||||
}
|
||||
|
||||
public function testLoadContractsReturnsNormalizedEntriesAndSummary(): void
|
||||
{
|
||||
$contracts = [
|
||||
[
|
||||
'No' => 'V0002',
|
||||
'Periodic_Invoicing_Type' => 'INTRANET',
|
||||
'Description' => 'Intranet',
|
||||
'State' => 'Aktiv',
|
||||
'Next_Invoicing_Date' => '2026-06-01',
|
||||
'Sell_to_Cust_No' => '10610',
|
||||
'Sell_to_Cust_Name' => 'Aero-Dienst GmbH',
|
||||
'Bill_to_Cust_No' => '10610',
|
||||
'Bill_to_Cust_Name' => 'Aero-Dienst GmbH',
|
||||
'TotalPayoffAmount' => 1200,
|
||||
'No_of_Active_Position' => 12,
|
||||
'Support_Hours' => 0.5,
|
||||
],
|
||||
[
|
||||
'No' => 'V0001',
|
||||
'Periodic_Invoicing_Type' => 'WEBHOSTING',
|
||||
'Description' => 'Hosting',
|
||||
'State' => 'Vorbereitung',
|
||||
'Next_Invoicing_Date' => '2026-05-01',
|
||||
'Sell_to_Cust_No' => '10610',
|
||||
'Sell_to_Cust_Name' => 'Aero-Dienst GmbH',
|
||||
'Bill_to_Cust_No' => '10610',
|
||||
'Bill_to_Cust_Name' => 'Aero-Dienst GmbH',
|
||||
'TotalPayoffAmount' => 600,
|
||||
'No_of_Active_Position' => 2,
|
||||
'Support_Hours' => 0.2,
|
||||
],
|
||||
[
|
||||
'No' => 'V9999',
|
||||
'Bill_to_Cust_No' => '99999',
|
||||
'Sell_to_Cust_No' => '99999',
|
||||
],
|
||||
];
|
||||
|
||||
$gateway = $this->createMock(BcODataGateway::class);
|
||||
$gateway->method('getContractsForCustomer')->willReturn($contracts);
|
||||
|
||||
$service = $this->createService($gateway);
|
||||
$result = $service->loadContracts('10610');
|
||||
|
||||
$this->assertTrue($result['ok']);
|
||||
$this->assertCount(2, $result['entries']);
|
||||
$this->assertSame('V0002', $result['entries'][0]['contract_no']);
|
||||
$this->assertSame(2, $result['summary']['total_contracts']);
|
||||
$this->assertSame(1, $result['summary']['active_contracts']);
|
||||
$this->assertSame('INTRANET', $result['summary']['product_types'][0]['type']);
|
||||
}
|
||||
|
||||
public function testLoadContractsReturnsErrorOnException(): void
|
||||
{
|
||||
$gateway = $this->createMock(BcODataGateway::class);
|
||||
$gateway->method('getContractsForCustomer')->willThrowException(new \RuntimeException('Contracts endpoint failed'));
|
||||
|
||||
$service = $this->createService($gateway);
|
||||
$result = $service->loadContracts('10610');
|
||||
|
||||
$this->assertFalse($result['ok']);
|
||||
$this->assertSame('Contracts endpoint failed', $result['error']);
|
||||
}
|
||||
|
||||
// --- loadTicketSummary() ---
|
||||
|
||||
public function testLoadTicketSummaryReturnsFalseForEmptyCustomerNo(): void
|
||||
@@ -342,6 +422,146 @@ class DebitorDetailServiceTest extends TestCase
|
||||
$this->assertSame('Timeout', $result['error']);
|
||||
}
|
||||
|
||||
// --- loadSupportDashboard() ---
|
||||
|
||||
public function testLoadSupportDashboardReturnsErrorWhenTicketLoadFails(): void
|
||||
{
|
||||
$gateway = $this->createMock(BcODataGateway::class);
|
||||
$gateway->method('getTicketsForCustomer')->willThrowException(new \RuntimeException('Timeout'));
|
||||
|
||||
$service = $this->createService($gateway);
|
||||
$result = $service->loadSupportDashboard('10001', 'Test GmbH');
|
||||
|
||||
$this->assertFalse($result['ok']);
|
||||
$this->assertSame('Timeout', $result['error']);
|
||||
}
|
||||
|
||||
public function testBuildSupportDashboardCalculatesHealthAndActions(): void
|
||||
{
|
||||
$now = new \DateTimeImmutable('now', new \DateTimeZone('UTC'));
|
||||
$staleDate = $now->modify('-72 hours')->format('Y-m-d\\TH:i:00\\Z');
|
||||
$freshDate = $now->modify('-6 hours')->format('Y-m-d\\TH:i:00\\Z');
|
||||
$midDate = $now->modify('-30 hours')->format('Y-m-d\\TH:i:00\\Z');
|
||||
|
||||
$tickets = [
|
||||
['No' => 'T001', 'Ticket_State' => 'Offen', 'Support_User_Name' => 'NKS', 'Last_Activity_Date' => $staleDate],
|
||||
['No' => 'T002', 'Ticket_State' => 'In Bearbeitung', 'Support_User_Name' => '', 'Last_Activity_Date' => $freshDate],
|
||||
['No' => 'T003', 'Ticket_State' => 'Open', 'Support_User_Name' => 'ABC', 'Last_Activity_Date' => $midDate],
|
||||
['No' => 'T004', 'Ticket_State' => 'Closed', 'Support_User_Name' => 'ABC', 'Last_Activity_Date' => $staleDate],
|
||||
];
|
||||
|
||||
$result = DebitorDetailService::buildSupportDashboard($tickets, 48);
|
||||
|
||||
$this->assertTrue($result['ok']);
|
||||
$this->assertSame(4, $result['health']['total_tickets']);
|
||||
$this->assertSame(3, $result['health']['open_tickets']);
|
||||
$this->assertSame(1, $result['health']['critical_tickets']);
|
||||
$this->assertIsInt($result['health']['oldest_open_age_hours']);
|
||||
$this->assertGreaterThanOrEqual(71, $result['health']['oldest_open_age_hours']);
|
||||
$this->assertNull($result['health']['escalation_count']);
|
||||
$this->assertFalse($result['health']['escalation_available']);
|
||||
|
||||
$this->assertSame(48, $result['meta']['critical_threshold_hours']);
|
||||
$this->assertSame(4, $result['meta']['tickets_considered']);
|
||||
|
||||
$this->assertCount(3, $result['actions']);
|
||||
$actionTypes = array_map(static fn (array $action): string => (string) $action['type'], $result['actions']);
|
||||
$this->assertSame(
|
||||
['critical_stale', 'unassigned_support', 'oldest_open_followup'],
|
||||
$actionTypes
|
||||
);
|
||||
}
|
||||
|
||||
public function testBuildSupportDashboardReturnsNoActionsWhenNoOpenTickets(): void
|
||||
{
|
||||
$tickets = [
|
||||
['No' => 'T001', 'Ticket_State' => 'Closed', 'Last_Activity_Date' => '2026-03-10T08:00:00Z'],
|
||||
['No' => 'T002', 'Ticket_State' => 'Erledigt', 'Last_Activity_Date' => '2026-03-10T08:00:00Z'],
|
||||
];
|
||||
|
||||
$result = DebitorDetailService::buildSupportDashboard($tickets, 48);
|
||||
|
||||
$this->assertTrue($result['ok']);
|
||||
$this->assertSame(0, $result['health']['open_tickets']);
|
||||
$this->assertSame(0, $result['health']['critical_tickets']);
|
||||
$this->assertNull($result['health']['oldest_open_age_hours']);
|
||||
$this->assertSame([], $result['actions']);
|
||||
}
|
||||
|
||||
// --- loadEscalationHealth() ---
|
||||
|
||||
public function testLoadEscalationHealthReturnsFalseForEmptyCustomerNo(): void
|
||||
{
|
||||
$service = $this->createService();
|
||||
$result = $service->loadEscalationHealth('', []);
|
||||
$this->assertFalse($result['ok']);
|
||||
}
|
||||
|
||||
public function testLoadEscalationHealthReturnsFalseWhenNotConfigured(): void
|
||||
{
|
||||
$service = $this->createService(null, false);
|
||||
$result = $service->loadEscalationHealth('10610', []);
|
||||
$this->assertFalse($result['ok']);
|
||||
}
|
||||
|
||||
public function testLoadEscalationHealthCalculatesOverdueEscalations(): void
|
||||
{
|
||||
$now = new \DateTimeImmutable('now', new \DateTimeZone('UTC'));
|
||||
$tooOld = $now->modify('-30 hours')->format('Y-m-d\\TH:i:00\\Z');
|
||||
$fresh = $now->modify('-2 hours')->format('Y-m-d\\TH:i:00\\Z');
|
||||
|
||||
$tickets = [
|
||||
['No' => 'T001', 'Ticket_State' => 'Offen', 'Last_Activity_Date' => $tooOld],
|
||||
['No' => 'T002', 'Ticket_State' => 'Open', 'Last_Activity_Date' => $fresh],
|
||||
['No' => 'T003', 'Ticket_State' => 'Closed', 'Last_Activity_Date' => $tooOld],
|
||||
];
|
||||
|
||||
$gateway = $this->createMock(BcODataGateway::class);
|
||||
$gateway->method('getEscalationDefinitions')->willReturn([
|
||||
['Code' => 'NORMAL', 'Target_Time' => 'P1DT0H0M0.0S'],
|
||||
['Code' => 'MAX', 'Target_Time' => 'P1DT0H0M0.0S'],
|
||||
]);
|
||||
$gateway->method('getEscalationTicketsForCustomer')->willReturn([
|
||||
['No' => 'T001', 'Escalation_Code' => 'NORMAL'],
|
||||
['No' => 'T002', 'Escalation_Code' => 'MAX'],
|
||||
]);
|
||||
|
||||
$service = $this->createService($gateway);
|
||||
$result = $service->loadEscalationHealth('10610', $tickets);
|
||||
|
||||
$this->assertTrue($result['ok']);
|
||||
$this->assertSame(2, $result['tickets_considered']);
|
||||
$this->assertSame(2, $result['tickets_matched']);
|
||||
$this->assertSame(1, $result['overdue_count']);
|
||||
$this->assertSame(2, $result['definitions_count']);
|
||||
$this->assertCount(1, $result['entries']);
|
||||
$this->assertSame('T001', $result['entries'][0]['ticket_no']);
|
||||
$this->assertSame('NORMAL', $result['entries'][0]['escalation_code']);
|
||||
$this->assertGreaterThan(0, $result['entries'][0]['overdue_seconds']);
|
||||
}
|
||||
|
||||
public function testBuildSupportDashboardUsesProvidedEscalationHealth(): void
|
||||
{
|
||||
$tickets = [
|
||||
['No' => 'T001', 'Ticket_State' => 'Offen', 'Last_Activity_Date' => '2026-03-10T08:00:00Z'],
|
||||
];
|
||||
|
||||
$result = DebitorDetailService::buildSupportDashboard($tickets, 48, [
|
||||
'ok' => true,
|
||||
'overdue_count' => 4,
|
||||
'tickets_considered' => 6,
|
||||
'tickets_matched' => 5,
|
||||
'definitions_count' => 15,
|
||||
]);
|
||||
|
||||
$this->assertTrue($result['ok']);
|
||||
$this->assertSame(4, $result['health']['escalation_count']);
|
||||
$this->assertTrue($result['health']['escalation_available']);
|
||||
$this->assertSame(6, $result['meta']['escalation_tickets_considered']);
|
||||
$this->assertSame(5, $result['meta']['escalation_tickets_matched']);
|
||||
$this->assertSame(15, $result['meta']['escalation_definitions_count']);
|
||||
}
|
||||
|
||||
// --- loadTicketLog() ---
|
||||
|
||||
public function testLoadTicketLogReturnsFalseForEmptyTicketNo(): void
|
||||
@@ -386,4 +606,450 @@ class DebitorDetailServiceTest extends TestCase
|
||||
$this->assertFalse($result['ok']);
|
||||
$this->assertSame('API error', $result['error']);
|
||||
}
|
||||
|
||||
// --- loadSalesDashboard() ---
|
||||
|
||||
public function testLoadSalesDashboardReturnsErrorForEmptyCustomerNo(): void
|
||||
{
|
||||
$service = $this->createService();
|
||||
$result = $service->loadSalesDashboard('', 'Firma');
|
||||
$this->assertFalse($result['ok']);
|
||||
}
|
||||
|
||||
public function testLoadSalesDashboardReturnsErrorWhenNotConfigured(): void
|
||||
{
|
||||
$service = $this->createService(null, false);
|
||||
$result = $service->loadSalesDashboard('10254', 'Firma');
|
||||
$this->assertFalse($result['ok']);
|
||||
}
|
||||
|
||||
public function testLoadSalesDashboardReturnsKpisAndContracts(): void
|
||||
{
|
||||
$contracts = [
|
||||
[
|
||||
'No' => 'V100',
|
||||
'State' => 'Aktiv',
|
||||
'Periodic_Invoicing_Type' => 'WA',
|
||||
'Description' => 'Wartungsvertrag',
|
||||
'Bill_to_Cust_No' => '10254',
|
||||
'Sell_to_Cust_No' => '10254',
|
||||
'MontlyAmount_InvoicingDate' => 500.0,
|
||||
'Support_Hours' => 10,
|
||||
'Next_Invoicing_Date' => '2026-05-01',
|
||||
'Starting_Date' => '2024-01-01',
|
||||
'No_of_Active_Position' => 3,
|
||||
],
|
||||
[
|
||||
'No' => 'V101',
|
||||
'State' => 'Aktiv',
|
||||
'Periodic_Invoicing_Type' => 'FB',
|
||||
'Description' => 'Hosting',
|
||||
'Bill_to_Cust_No' => '10254',
|
||||
'Sell_to_Cust_No' => '10254',
|
||||
'MontlyAmount_InvoicingDate' => 250.0,
|
||||
'Support_Hours' => 5,
|
||||
'Next_Invoicing_Date' => '2026-04-15',
|
||||
'Starting_Date' => '2023-06-01',
|
||||
'No_of_Active_Position' => 2,
|
||||
],
|
||||
];
|
||||
$lines = [
|
||||
[
|
||||
'Header_No' => 'V100',
|
||||
'Line_No' => 10000,
|
||||
'Description' => 'Server-Wartung',
|
||||
'Type' => 'Artikel',
|
||||
'Quantity' => 1,
|
||||
'Unit_Price' => 500,
|
||||
'Line_Amount' => 500,
|
||||
'Line_State' => 'Aktiv',
|
||||
],
|
||||
];
|
||||
|
||||
$gateway = $this->createMock(BcODataGateway::class);
|
||||
$gateway->method('getContractsForCustomer')->willReturn($contracts);
|
||||
$gateway->method('getContractLinesForCustomer')->willReturn($lines);
|
||||
$gateway->method('getContactsForCustomer')->willReturn([]);
|
||||
|
||||
$service = $this->createService($gateway);
|
||||
$result = $service->loadSalesDashboard('10254', 'Musterfirma');
|
||||
|
||||
$this->assertTrue($result['ok']);
|
||||
$this->assertSame(2, $result['kpis']['active_contracts']);
|
||||
$this->assertSame(750.0, $result['kpis']['monthly_volume']);
|
||||
$this->assertSame(15.0, $result['kpis']['support_hours']);
|
||||
$this->assertSame('2026-04-15', $result['kpis']['next_invoicing_date']);
|
||||
$this->assertCount(2, $result['contracts']);
|
||||
// Find contract V100 which has lines
|
||||
$v100 = null;
|
||||
foreach ($result['contracts'] as $c) {
|
||||
if ($c['contract_no'] === 'V100') {
|
||||
$v100 = $c;
|
||||
}
|
||||
}
|
||||
$this->assertNotNull($v100);
|
||||
$this->assertCount(1, $v100['lines']);
|
||||
}
|
||||
|
||||
// --- buildSalesDashboard() ---
|
||||
|
||||
public function testBuildSalesDashboardKpisOnlyCountActiveContracts(): void
|
||||
{
|
||||
$contracts = [
|
||||
['No' => 'V1', 'State' => 'Aktiv', 'Bill_to_Cust_No' => '10', 'Sell_to_Cust_No' => '10', 'MontlyAmount_InvoicingDate' => 100, 'Support_Hours' => 5, 'Next_Invoicing_Date' => '2026-05-01'],
|
||||
['No' => 'V2', 'State' => 'Beendet', 'Bill_to_Cust_No' => '10', 'Sell_to_Cust_No' => '10', 'MontlyAmount_InvoicingDate' => 200, 'Support_Hours' => 10, 'Next_Invoicing_Date' => '2025-01-01'],
|
||||
];
|
||||
|
||||
$result = DebitorDetailService::buildSalesDashboard($contracts, [], '10');
|
||||
|
||||
$this->assertSame(1, $result['kpis']['active_contracts']);
|
||||
$this->assertSame(100.0, $result['kpis']['monthly_volume']);
|
||||
$this->assertSame(5.0, $result['kpis']['support_hours']);
|
||||
}
|
||||
|
||||
public function testBuildSalesDashboardFiltersOldEndedContracts(): void
|
||||
{
|
||||
$contracts = [
|
||||
['No' => 'V1', 'State' => 'Beendet', 'Bill_to_Cust_No' => '10', 'Sell_to_Cust_No' => '10', 'Next_Invoicing_Date' => '2020-01-01', 'Starting_Date' => '2019-01-01'],
|
||||
['No' => 'V2', 'State' => 'Beendet', 'Bill_to_Cust_No' => '10', 'Sell_to_Cust_No' => '10', 'Next_Invoicing_Date' => '2025-06-01', 'Starting_Date' => '2024-01-01'],
|
||||
['No' => 'V3', 'State' => 'Aktiv', 'Bill_to_Cust_No' => '10', 'Sell_to_Cust_No' => '10', 'Next_Invoicing_Date' => '2026-05-01'],
|
||||
];
|
||||
|
||||
$result = DebitorDetailService::buildSalesDashboard($contracts, [], '10');
|
||||
|
||||
$contractNos = array_column($result['contracts'], 'contract_no');
|
||||
$this->assertNotContains('V1', $contractNos);
|
||||
$this->assertContains('V2', $contractNos);
|
||||
$this->assertContains('V3', $contractNos);
|
||||
}
|
||||
|
||||
public function testBuildSalesDashboardGroupsLinesByContract(): void
|
||||
{
|
||||
$contracts = [
|
||||
['No' => 'V1', 'State' => 'Aktiv', 'Bill_to_Cust_No' => '10', 'Sell_to_Cust_No' => '10'],
|
||||
['No' => 'V2', 'State' => 'Aktiv', 'Bill_to_Cust_No' => '10', 'Sell_to_Cust_No' => '10'],
|
||||
];
|
||||
$lines = [
|
||||
['Header_No' => 'V1', 'Line_No' => 10000, 'Description' => 'Line A', 'Type' => 'Artikel', 'Quantity' => 1, 'Unit_Price' => 10, 'Line_Amount' => 10, 'Line_State' => 'Aktiv'],
|
||||
['Header_No' => 'V1', 'Line_No' => 20000, 'Description' => 'Line B', 'Type' => 'Wartung', 'Quantity' => 2, 'Unit_Price' => 20, 'Line_Amount' => 40, 'Line_State' => 'Aktiv'],
|
||||
['Header_No' => 'V2', 'Line_No' => 10000, 'Description' => 'Line C', 'Type' => 'Artikel', 'Quantity' => 1, 'Unit_Price' => 50, 'Line_Amount' => 50, 'Line_State' => 'Aktiv'],
|
||||
];
|
||||
|
||||
$result = DebitorDetailService::buildSalesDashboard($contracts, $lines, '10');
|
||||
|
||||
$this->assertCount(2, $result['contracts']);
|
||||
$v1 = $result['contracts'][0]['contract_no'] === 'V1' ? $result['contracts'][0] : $result['contracts'][1];
|
||||
$v2 = $result['contracts'][0]['contract_no'] === 'V2' ? $result['contracts'][0] : $result['contracts'][1];
|
||||
$this->assertCount(2, $v1['lines']);
|
||||
$this->assertCount(1, $v2['lines']);
|
||||
}
|
||||
|
||||
public function testBuildSalesDashboardSortsActiveFirst(): void
|
||||
{
|
||||
$contracts = [
|
||||
['No' => 'V1', 'State' => 'Beendet', 'Bill_to_Cust_No' => '10', 'Sell_to_Cust_No' => '10', 'Next_Invoicing_Date' => '2025-06-01'],
|
||||
['No' => 'V2', 'State' => 'Aktiv', 'Bill_to_Cust_No' => '10', 'Sell_to_Cust_No' => '10', 'Next_Invoicing_Date' => '2026-05-01'],
|
||||
];
|
||||
|
||||
$result = DebitorDetailService::buildSalesDashboard($contracts, [], '10');
|
||||
|
||||
$this->assertSame('V2', $result['contracts'][0]['contract_no']);
|
||||
$this->assertSame('V1', $result['contracts'][1]['contract_no']);
|
||||
}
|
||||
|
||||
// --- buildControllingDashboard() ---
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private static function closedTicket(string $no, int $createdHoursAgo, int $resolutionHours): array
|
||||
{
|
||||
$now = time();
|
||||
$createdTs = $now - ($createdHoursAgo * 3600);
|
||||
$activityTs = $createdTs + ($resolutionHours * 3600);
|
||||
|
||||
return [
|
||||
'No' => $no,
|
||||
'Customer_No' => '10610',
|
||||
'Ticket_State' => 'Erledigt',
|
||||
'Process_Stage' => 40,
|
||||
'Created_On' => gmdate('Y-m-d\\TH:i:s\\Z', $createdTs),
|
||||
'Last_Activity_Date' => gmdate('Y-m-d\\TH:i:s\\Z', $activityTs),
|
||||
'Escalation_Code' => '',
|
||||
'Support_User_Name' => 'NKS',
|
||||
'Category_1_Code' => 'BUG',
|
||||
'Process_Code' => '',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private static function openTicket(string $no, int $createdHoursAgo, string $supportUser = 'NKS', string $escalationCode = ''): array
|
||||
{
|
||||
$now = time();
|
||||
$createdTs = $now - ($createdHoursAgo * 3600);
|
||||
|
||||
return [
|
||||
'No' => $no,
|
||||
'Customer_No' => '10610',
|
||||
'Ticket_State' => 'Offen',
|
||||
'Process_Stage' => 10,
|
||||
'Created_On' => gmdate('Y-m-d\\TH:i:s\\Z', $createdTs),
|
||||
'Last_Activity_Date' => gmdate('Y-m-d\\TH:i:s\\Z', $createdTs),
|
||||
'Escalation_Code' => $escalationCode,
|
||||
'Support_User_Name' => $supportUser,
|
||||
'Category_1_Code' => 'SUPPORT',
|
||||
'Process_Code' => '',
|
||||
];
|
||||
}
|
||||
|
||||
private static function defaultRiskConfig(): array
|
||||
{
|
||||
return [
|
||||
'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],
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
// --- KPI 1: Volume ---
|
||||
|
||||
public function testBuildControllingDashboardReturnsEmptyStateForNoTickets(): void
|
||||
{
|
||||
$result = DebitorDetailService::buildControllingDashboard([], 90, self::defaultRiskConfig());
|
||||
|
||||
$this->assertSame(0, $result['kpis']['volume']);
|
||||
$this->assertSame(0, $result['kpis']['volume_prev']);
|
||||
$this->assertSame(0, $result['kpis']['volume_delta']);
|
||||
$this->assertNull($result['kpis']['close_rate']);
|
||||
$this->assertNull($result['kpis']['resolution_hours']);
|
||||
$this->assertSame(0, $result['kpis']['backlog_open']);
|
||||
$this->assertSame(90, $result['meta']['period_days']);
|
||||
$this->assertSame(0, $result['meta']['created_in_period']);
|
||||
}
|
||||
|
||||
public function testBuildControllingDashboardVolumeCounts(): void
|
||||
{
|
||||
$tickets = [
|
||||
self::openTicket('T001', 24),
|
||||
self::openTicket('T002', 48),
|
||||
self::closedTicket('T003', 72, 12),
|
||||
];
|
||||
|
||||
$result = DebitorDetailService::buildControllingDashboard($tickets, 90, self::defaultRiskConfig());
|
||||
|
||||
$this->assertSame(3, $result['kpis']['volume']);
|
||||
$this->assertSame(0, $result['kpis']['volume_prev']);
|
||||
$this->assertSame(3, $result['kpis']['volume_delta']);
|
||||
}
|
||||
|
||||
public function testBuildControllingDashboardVolumeDeltaWithPrevPeriod(): void
|
||||
{
|
||||
$tickets = [
|
||||
// 3 in current period (within 90 days)
|
||||
self::openTicket('T001', 24),
|
||||
self::openTicket('T002', 48),
|
||||
self::closedTicket('T003', 72, 12),
|
||||
// 5 in previous period (90-180 days ago)
|
||||
self::closedTicket('TP1', 24 * 95, 10),
|
||||
self::closedTicket('TP2', 24 * 100, 10),
|
||||
self::closedTicket('TP3', 24 * 110, 10),
|
||||
self::closedTicket('TP4', 24 * 120, 10),
|
||||
self::closedTicket('TP5', 24 * 130, 10),
|
||||
];
|
||||
|
||||
$result = DebitorDetailService::buildControllingDashboard($tickets, 90, self::defaultRiskConfig());
|
||||
|
||||
$this->assertSame(3, $result['kpis']['volume']);
|
||||
$this->assertSame(5, $result['kpis']['volume_prev']);
|
||||
$this->assertSame(-2, $result['kpis']['volume_delta']);
|
||||
}
|
||||
|
||||
// --- KPI 2: Close-Rate ---
|
||||
|
||||
public function testBuildControllingDashboardCloseRate(): void
|
||||
{
|
||||
// 5 created, 3 closed in period → 3/5 = 0.6
|
||||
$tickets = [
|
||||
self::openTicket('T001', 24),
|
||||
self::openTicket('T002', 48),
|
||||
self::closedTicket('T003', 72, 12),
|
||||
self::closedTicket('T004', 48, 6),
|
||||
self::closedTicket('T005', 24, 4),
|
||||
];
|
||||
|
||||
$result = DebitorDetailService::buildControllingDashboard($tickets, 90, self::defaultRiskConfig());
|
||||
|
||||
$this->assertSame(0.6, $result['kpis']['close_rate']);
|
||||
$this->assertSame(5, $result['meta']['created_in_period']);
|
||||
$this->assertSame(3, $result['meta']['closed_in_period']);
|
||||
}
|
||||
|
||||
public function testBuildControllingDashboardCloseRateNullWhenNoCreated(): void
|
||||
{
|
||||
$result = DebitorDetailService::buildControllingDashboard([], 90, self::defaultRiskConfig());
|
||||
|
||||
$this->assertNull($result['kpis']['close_rate']);
|
||||
}
|
||||
|
||||
public function testBuildControllingDashboardCloseRateCountsOldTicketsClosedInPeriod(): void
|
||||
{
|
||||
// Ticket created 200h ago (in period), closed 10h after creation (still in period)
|
||||
// Plus a ticket created 400h ago (before 90d) that was closed 10h ago (in period)
|
||||
$tickets = [
|
||||
self::closedTicket('T001', 200, 10), // created in period, closed in period
|
||||
self::closedTicket('T002', 2200, 10), // created before period, closed ~2190h ago — NOT in period
|
||||
];
|
||||
|
||||
$result = DebitorDetailService::buildControllingDashboard($tickets, 90, self::defaultRiskConfig());
|
||||
|
||||
// Only T001 created in period
|
||||
$this->assertSame(1, $result['kpis']['volume']);
|
||||
// T001 closed in period, T002 closed long ago — only T001
|
||||
$this->assertSame(1, $result['meta']['closed_in_period']);
|
||||
$this->assertSame(1.0, $result['kpis']['close_rate']);
|
||||
}
|
||||
|
||||
// --- KPI 3: Resolution Time ---
|
||||
|
||||
public function testBuildControllingDashboardResolutionMedianForClosedInPeriod(): void
|
||||
{
|
||||
// 3 tickets closed in period with resolution times: 10h, 50h, 30h
|
||||
// All created in period too — sorted: 10, 30, 50 → median: 30
|
||||
$tickets = [
|
||||
self::closedTicket('T001', 48, 10),
|
||||
self::closedTicket('T002', 48, 50),
|
||||
self::closedTicket('T003', 48, 30),
|
||||
];
|
||||
|
||||
$result = DebitorDetailService::buildControllingDashboard($tickets, 90, self::defaultRiskConfig());
|
||||
|
||||
$this->assertSame(30.0, $result['kpis']['resolution_hours']);
|
||||
}
|
||||
|
||||
public function testBuildControllingDashboardResolutionNullWhenNoClosed(): void
|
||||
{
|
||||
$tickets = [
|
||||
self::openTicket('T001', 24),
|
||||
self::openTicket('T002', 48),
|
||||
];
|
||||
|
||||
$result = DebitorDetailService::buildControllingDashboard($tickets, 90, self::defaultRiskConfig());
|
||||
|
||||
$this->assertNull($result['kpis']['resolution_hours']);
|
||||
}
|
||||
|
||||
public function testBuildControllingDashboardResolutionPrevPeriod(): void
|
||||
{
|
||||
// Current period: one ticket, 20h resolution
|
||||
$tickets = [
|
||||
self::closedTicket('T001', 48, 20),
|
||||
];
|
||||
// Previous period: one ticket, 80h resolution
|
||||
$tickets[] = self::closedTicket('TP1', 24 * 100, 80);
|
||||
|
||||
$result = DebitorDetailService::buildControllingDashboard($tickets, 90, self::defaultRiskConfig());
|
||||
|
||||
$this->assertSame(20.0, $result['kpis']['resolution_hours']);
|
||||
$this->assertSame(80.0, $result['kpis']['resolution_hours_prev']);
|
||||
}
|
||||
|
||||
// --- KPI 4: Open Backlog ---
|
||||
|
||||
public function testBuildControllingDashboardBacklog(): void
|
||||
{
|
||||
$tickets = [
|
||||
self::openTicket('T001', 24, 'NKS'), // assigned, fresh
|
||||
self::openTicket('T002', 200, ''), // unassigned, >48h → critical
|
||||
self::openTicket('T003', 60, 'ABC'), // assigned, >48h → critical
|
||||
self::closedTicket('T004', 48, 10), // closed, not in backlog
|
||||
];
|
||||
|
||||
$result = DebitorDetailService::buildControllingDashboard($tickets, 90, self::defaultRiskConfig());
|
||||
|
||||
$this->assertSame(3, $result['kpis']['backlog_open']);
|
||||
$this->assertSame(1, $result['kpis']['backlog_unassigned']);
|
||||
$this->assertSame(2, $result['kpis']['backlog_critical']);
|
||||
}
|
||||
|
||||
// --- Risk ---
|
||||
|
||||
public function testBuildControllingDashboardEvaluatesRiskRules(): void
|
||||
{
|
||||
$tickets = [];
|
||||
|
||||
// 8 tickets in current period
|
||||
for ($i = 1; $i <= 5; $i++) {
|
||||
$tickets[] = self::closedTicket('TC' . $i, 24 * $i, 10);
|
||||
}
|
||||
$tickets[] = self::openTicket('TO1', 200, 'NKS', 'MAX');
|
||||
$tickets[] = self::openTicket('TO2', 24, '', 'MAX');
|
||||
$tickets[] = self::openTicket('TO3', 48, 'ABC', 'HOCH');
|
||||
|
||||
// 2 tickets in previous period
|
||||
$tickets[] = self::closedTicket('TP1', 24 * 100, 10);
|
||||
$tickets[] = self::closedTicket('TP2', 24 * 110, 10);
|
||||
|
||||
$result = DebitorDetailService::buildControllingDashboard($tickets, 90, self::defaultRiskConfig());
|
||||
|
||||
$this->assertGreaterThanOrEqual(3, $result['risk_summary']['triggered']);
|
||||
$this->assertSame(4, $result['risk_summary']['total']);
|
||||
$this->assertSame('high', $result['risk_summary']['level']);
|
||||
|
||||
$this->assertCount(4, $result['risk_indicators']);
|
||||
foreach ($result['risk_indicators'] as $indicator) {
|
||||
$this->assertArrayHasKey('rule_id', $indicator);
|
||||
$this->assertArrayHasKey('triggered', $indicator);
|
||||
$this->assertArrayHasKey('value', $indicator);
|
||||
$this->assertArrayHasKey('threshold', $indicator);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Trend buckets ---
|
||||
|
||||
public function testBuildControllingDashboardBucketsByMonth(): void
|
||||
{
|
||||
$tickets = [
|
||||
self::openTicket('T001', 24),
|
||||
self::openTicket('T002', 24 * 20, 'NKS'),
|
||||
self::openTicket('T003', 24 * 50, 'NKS'),
|
||||
];
|
||||
|
||||
$result = DebitorDetailService::buildControllingDashboard($tickets, 90, self::defaultRiskConfig());
|
||||
|
||||
$this->assertGreaterThanOrEqual(3, count($result['trend']));
|
||||
|
||||
foreach ($result['trend'] as $bucket) {
|
||||
$this->assertArrayHasKey('label', $bucket);
|
||||
$this->assertArrayHasKey('created', $bucket);
|
||||
$this->assertArrayHasKey('closed', $bucket);
|
||||
}
|
||||
|
||||
$totalCreated = array_sum(array_column($result['trend'], 'created'));
|
||||
$this->assertSame($result['meta']['created_in_period'], $totalCreated);
|
||||
}
|
||||
|
||||
// --- Contract metrics ---
|
||||
|
||||
// --- Efficiency ---
|
||||
|
||||
public function testBuildControllingDashboardEfficiencyMetrics(): void
|
||||
{
|
||||
$tickets = [
|
||||
self::openTicket('T001', 48, 'NKS'),
|
||||
self::openTicket('T002', 24, ''),
|
||||
self::closedTicket('T003', 72, 20),
|
||||
];
|
||||
|
||||
$result = DebitorDetailService::buildControllingDashboard($tickets, 90, self::defaultRiskConfig());
|
||||
|
||||
$this->assertSame(20.0, $result['efficiency']['resolution_hours']);
|
||||
$this->assertSame(1, $result['efficiency']['unassigned_count']);
|
||||
$this->assertIsInt($result['efficiency']['oldest_open_hours']);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -192,4 +192,190 @@ class HelpdeskSettingsGatewayTest extends TestCase
|
||||
$gateway = $this->createGateway($settings);
|
||||
$this->assertFalse($gateway->setOAuthTokenEndpoint('http://login.microsoftonline.com/token'));
|
||||
}
|
||||
|
||||
public function testGetSystemRecommendationsConfigEnvelopeReturnsDefaultsWhenMissing(): void
|
||||
{
|
||||
$settings = $this->createMock(SettingRepositoryInterface::class);
|
||||
$settings->method('getValue')->willReturnMap([
|
||||
[HelpdeskSettingsGateway::KEY_SYSTEM_RECOMMENDATIONS_CONFIG, null],
|
||||
]);
|
||||
|
||||
$gateway = $this->createGateway($settings);
|
||||
$envelope = $gateway->getSystemRecommendationsConfigEnvelope();
|
||||
|
||||
$this->assertSame('default', $envelope['source']);
|
||||
$this->assertSame(5, $envelope['config']['max_items']);
|
||||
$this->assertTrue($envelope['config']['rules']['stale_open']['enabled']);
|
||||
}
|
||||
|
||||
public function testGetSystemRecommendationsConfigEnvelopeFallsBackOnInvalidJson(): void
|
||||
{
|
||||
$settings = $this->createMock(SettingRepositoryInterface::class);
|
||||
$settings->method('getValue')->willReturnMap([
|
||||
[HelpdeskSettingsGateway::KEY_SYSTEM_RECOMMENDATIONS_CONFIG, '{invalid'],
|
||||
]);
|
||||
|
||||
$gateway = $this->createGateway($settings);
|
||||
$envelope = $gateway->getSystemRecommendationsConfigEnvelope();
|
||||
|
||||
$this->assertSame('fallback_invalid_json', $envelope['source']);
|
||||
$this->assertSame(5, $envelope['config']['max_items']);
|
||||
}
|
||||
|
||||
public function testGetSystemRecommendationsConfigNormalizesValues(): void
|
||||
{
|
||||
$rawConfig = json_encode([
|
||||
'version' => 99,
|
||||
'max_items' => 99,
|
||||
'rules' => [
|
||||
'high_risk_aging' => [
|
||||
'enabled' => true,
|
||||
'priority' => '120',
|
||||
'codes' => 'MAX, HOCH,',
|
||||
'min_age_hours' => '12',
|
||||
],
|
||||
'customer_backlog' => [
|
||||
'enabled' => '0',
|
||||
'priority' => 0,
|
||||
'min_open_tickets' => -1,
|
||||
],
|
||||
],
|
||||
], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
|
||||
|
||||
$settings = $this->createMock(SettingRepositoryInterface::class);
|
||||
$settings->method('getValue')->willReturnMap([
|
||||
[HelpdeskSettingsGateway::KEY_SYSTEM_RECOMMENDATIONS_CONFIG, $rawConfig],
|
||||
]);
|
||||
|
||||
$gateway = $this->createGateway($settings);
|
||||
$config = $gateway->getSystemRecommendationsConfig();
|
||||
|
||||
$this->assertSame(20, $config['max_items']);
|
||||
$this->assertSame(['MAX', 'HOCH'], $config['rules']['high_risk_aging']['codes']);
|
||||
$this->assertSame(12, $config['rules']['high_risk_aging']['min_age_hours']);
|
||||
$this->assertFalse($config['rules']['customer_backlog']['enabled']);
|
||||
$this->assertSame(1, $config['rules']['customer_backlog']['priority']);
|
||||
$this->assertSame(1, $config['rules']['customer_backlog']['min_open_tickets']);
|
||||
}
|
||||
|
||||
public function testSetSystemRecommendationsConfigPersistsNormalizedJson(): void
|
||||
{
|
||||
$settings = $this->createMock(SettingRepositoryInterface::class);
|
||||
$settings->expects($this->once())
|
||||
->method('set')
|
||||
->with(
|
||||
HelpdeskSettingsGateway::KEY_SYSTEM_RECOMMENDATIONS_CONFIG,
|
||||
$this->callback(static function (mixed $value): bool {
|
||||
if (!is_string($value) || $value === '') {
|
||||
return false;
|
||||
}
|
||||
|
||||
$decoded = json_decode($value, true);
|
||||
if (!is_array($decoded)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return ($decoded['version'] ?? null) === 1
|
||||
&& ($decoded['max_items'] ?? null) === 1
|
||||
&& (($decoded['rules']['high_risk_aging']['codes'] ?? []) === ['MAX', 'HOCH']);
|
||||
}),
|
||||
'helpdesk.system_recommendations_config'
|
||||
)
|
||||
->willReturn(true);
|
||||
|
||||
$gateway = $this->createGateway($settings);
|
||||
$result = $gateway->setSystemRecommendationsConfig([
|
||||
'max_items' => -10,
|
||||
'rules' => [
|
||||
'high_risk_aging' => [
|
||||
'codes' => '',
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
$this->assertTrue($result);
|
||||
}
|
||||
|
||||
// --- Controlling Risk Config ---
|
||||
|
||||
public function testGetControllingRiskConfigEnvelopeReturnsDefaultWhenNotSet(): void
|
||||
{
|
||||
$settings = $this->createMock(SettingRepositoryInterface::class);
|
||||
$settings->method('getValue')->willReturnMap([
|
||||
[HelpdeskSettingsGateway::KEY_CONTROLLING_RISK_CONFIG, null],
|
||||
]);
|
||||
|
||||
$gateway = $this->createGateway($settings);
|
||||
$envelope = $gateway->getControllingRiskConfigEnvelope();
|
||||
|
||||
$this->assertSame('default', $envelope['source']);
|
||||
$this->assertSame(1, $envelope['config']['version']);
|
||||
$this->assertTrue($envelope['config']['rules']['ticket_volume_increase']['enabled']);
|
||||
$this->assertSame(30, $envelope['config']['rules']['ticket_volume_increase']['threshold_percent']);
|
||||
$this->assertSame(72, $envelope['config']['rules']['avg_resolution_high']['threshold_hours']);
|
||||
}
|
||||
|
||||
public function testSetControllingRiskConfigNormalizesValues(): void
|
||||
{
|
||||
$settings = $this->createMock(SettingRepositoryInterface::class);
|
||||
$settings->expects($this->once())
|
||||
->method('set')
|
||||
->with(
|
||||
HelpdeskSettingsGateway::KEY_CONTROLLING_RISK_CONFIG,
|
||||
$this->callback(static function (mixed $value): bool {
|
||||
if (!is_string($value) || $value === '') {
|
||||
return false;
|
||||
}
|
||||
|
||||
$decoded = json_decode($value, true);
|
||||
if (!is_array($decoded)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return ($decoded['version'] ?? null) === 1
|
||||
&& ($decoded['rules']['ticket_volume_increase']['threshold_percent'] ?? null) === 50;
|
||||
}),
|
||||
'helpdesk.controlling_risk_config'
|
||||
)
|
||||
->willReturn(true);
|
||||
|
||||
$gateway = $this->createGateway($settings);
|
||||
$result = $gateway->setControllingRiskConfig([
|
||||
'rules' => [
|
||||
'ticket_volume_increase' => ['threshold_percent' => 50],
|
||||
],
|
||||
]);
|
||||
|
||||
$this->assertTrue($result);
|
||||
}
|
||||
|
||||
public function testNormalizeControllingRiskConfigEnforcesMinMax(): void
|
||||
{
|
||||
$rawConfig = json_encode([
|
||||
'version' => 1,
|
||||
'rules' => [
|
||||
'ticket_volume_increase' => ['enabled' => true, 'threshold_percent' => 999],
|
||||
'avg_resolution_high' => ['enabled' => true, 'threshold_hours' => 0],
|
||||
'open_aging' => ['enabled' => true, 'threshold_hours' => 5000],
|
||||
'unassigned_ratio' => ['enabled' => true, 'threshold_percent' => 101],
|
||||
],
|
||||
], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
|
||||
|
||||
$settings = $this->createMock(SettingRepositoryInterface::class);
|
||||
$settings->method('getValue')->willReturnMap([
|
||||
[HelpdeskSettingsGateway::KEY_CONTROLLING_RISK_CONFIG, $rawConfig],
|
||||
]);
|
||||
|
||||
$gateway = $this->createGateway($settings);
|
||||
$config = $gateway->getControllingRiskConfig();
|
||||
|
||||
// ticket_volume_increase: 999 > 200 → clamped to max 200
|
||||
$this->assertSame(200, $config['rules']['ticket_volume_increase']['threshold_percent']);
|
||||
// avg_resolution_high: 0 < 1 → clamped to min 1
|
||||
$this->assertSame(1, $config['rules']['avg_resolution_high']['threshold_hours']);
|
||||
// open_aging: 5000 > 2160 → clamped to max 2160
|
||||
$this->assertSame(2160, $config['rules']['open_aging']['threshold_hours']);
|
||||
// unassigned_ratio: 101 > 100 → clamped to max 100
|
||||
$this->assertSame(100, $config['rules']['unassigned_ratio']['threshold_percent']);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Tests\Module\Helpdesk\Service;
|
||||
|
||||
use MintyPHP\Module\Helpdesk\Service\SystemRecommendationEngine;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
class SystemRecommendationEngineTest extends TestCase
|
||||
{
|
||||
public function testBuildRecommendationsAppliesRulesSortsAndLimits(): void
|
||||
{
|
||||
$engine = new SystemRecommendationEngine();
|
||||
$config = [
|
||||
'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' => 3],
|
||||
],
|
||||
];
|
||||
|
||||
$tickets = [
|
||||
$this->openTicket('T001', 80, 'USR1'),
|
||||
$this->openTicket('T002', 10, 'USR2'),
|
||||
$this->openTicket('T003', 6, ''),
|
||||
$this->openTicket('T004', 60, 'USR3'),
|
||||
$this->openTicket('T005', 5, 'USR4'),
|
||||
['No' => 'T999', 'Ticket_State' => 'Closed', 'Last_Activity_Date' => $this->isoHoursAgo(120)],
|
||||
];
|
||||
|
||||
$escalationHealth = [
|
||||
'ok' => true,
|
||||
'entries' => [
|
||||
['ticket_no' => 'T001', 'escalation_code' => 'MAX', 'overdue_seconds' => 72000],
|
||||
['ticket_no' => 'T004', 'escalation_code' => 'NORMAL', 'overdue_seconds' => 600],
|
||||
['ticket_no' => 'T001', 'escalation_code' => 'MAX', 'overdue_seconds' => 72000], // duplicate
|
||||
],
|
||||
'matches' => [
|
||||
['ticket_no' => 'T001', 'escalation_code' => 'MAX', 'age_hours' => 80, 'is_overdue' => true],
|
||||
['ticket_no' => 'T002', 'escalation_code' => 'HOCH', 'age_hours' => 10, 'is_overdue' => false],
|
||||
['ticket_no' => 'T004', 'escalation_code' => 'NORMAL', 'age_hours' => 60, 'is_overdue' => true],
|
||||
],
|
||||
];
|
||||
|
||||
$result = $engine->buildRecommendations($tickets, $escalationHealth, $config);
|
||||
|
||||
$this->assertCount(5, $result['recommendations']);
|
||||
$this->assertSame('escalation_overdue', $result['recommendations'][0]['rule_id']);
|
||||
$this->assertSame('T001', $result['recommendations'][0]['ticket_no']);
|
||||
$this->assertSame('escalation_overdue', $result['recommendations'][1]['rule_id']);
|
||||
$this->assertSame('high_risk_aging', $result['recommendations'][2]['rule_id']);
|
||||
$this->assertSame('helpdesk.recommendation.high_risk_aging', $result['recommendations'][2]['message_key']);
|
||||
$this->assertSame(
|
||||
['escalation_overdue', 'high_risk_aging', 'unassigned_open', 'stale_open', 'customer_backlog'],
|
||||
$result['meta']['applied_rules']
|
||||
);
|
||||
$this->assertTrue($result['meta']['escalation_data_available']);
|
||||
}
|
||||
|
||||
public function testBuildRecommendationsSkipsEscalationRulesWhenUnavailable(): void
|
||||
{
|
||||
$engine = new SystemRecommendationEngine();
|
||||
$config = [
|
||||
'max_items' => 10,
|
||||
'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' => 1],
|
||||
],
|
||||
];
|
||||
|
||||
$tickets = [
|
||||
$this->openTicket('T010', 72, ''),
|
||||
];
|
||||
|
||||
$result = $engine->buildRecommendations($tickets, null, $config);
|
||||
$ruleIds = array_map(static fn (array $item): string => (string) $item['rule_id'], $result['recommendations']);
|
||||
|
||||
$this->assertFalse($result['meta']['escalation_data_available']);
|
||||
$this->assertNotContains('escalation_overdue', $ruleIds);
|
||||
$this->assertNotContains('high_risk_aging', $ruleIds);
|
||||
$this->assertContains('unassigned_open', $ruleIds);
|
||||
$this->assertContains('stale_open', $ruleIds);
|
||||
$this->assertContains('customer_backlog', $ruleIds);
|
||||
}
|
||||
|
||||
public function testBuildRecommendationsCreatesBacklogRecommendationWithOldestTicket(): void
|
||||
{
|
||||
$engine = new SystemRecommendationEngine();
|
||||
$config = [
|
||||
'max_items' => 5,
|
||||
'rules' => [
|
||||
'escalation_overdue' => ['enabled' => false, 'priority' => 100, 'min_overdue_minutes' => 0],
|
||||
'high_risk_aging' => ['enabled' => false, 'priority' => 90, 'codes' => ['MAX', 'HOCH'], 'min_age_hours' => 8],
|
||||
'unassigned_open' => ['enabled' => false, 'priority' => 80, 'min_age_hours' => 2],
|
||||
'stale_open' => ['enabled' => false, 'priority' => 70, 'min_age_hours' => 48],
|
||||
'customer_backlog' => ['enabled' => true, 'priority' => 60, 'min_open_tickets' => 3],
|
||||
],
|
||||
];
|
||||
|
||||
$tickets = [
|
||||
$this->openTicket('T100', 12, 'A'),
|
||||
$this->openTicket('T101', 44, 'B'),
|
||||
$this->openTicket('T102', 9, 'C'),
|
||||
];
|
||||
|
||||
$result = $engine->buildRecommendations($tickets, null, $config);
|
||||
|
||||
$this->assertCount(1, $result['recommendations']);
|
||||
$this->assertSame('customer_backlog', $result['recommendations'][0]['rule_id']);
|
||||
$this->assertSame('T101', $result['recommendations'][0]['ticket_no']);
|
||||
$this->assertSame(3, $result['recommendations'][0]['message_params']['open_tickets']);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function openTicket(string $ticketNo, int $ageHours, string $supportUserName): array
|
||||
{
|
||||
return [
|
||||
'No' => $ticketNo,
|
||||
'Ticket_State' => 'Open',
|
||||
'Support_User_Name' => $supportUserName,
|
||||
'Last_Activity_Date' => $this->isoHoursAgo($ageHours),
|
||||
];
|
||||
}
|
||||
|
||||
private function isoHoursAgo(int $hours): string
|
||||
{
|
||||
$dt = new \DateTimeImmutable('now', new \DateTimeZone('UTC'));
|
||||
$dt = $dt->modify('-' . max(0, $hours) . ' hours');
|
||||
|
||||
return $dt->format('Y-m-d\\TH:i:00\\Z');
|
||||
}
|
||||
}
|
||||
@@ -106,6 +106,7 @@ class TicketCommunicationServiceTest extends TestCase
|
||||
|
||||
$odataGateway = $this->createMock(BcODataGateway::class);
|
||||
$odataGateway->method('getTicketsForCustomer')->with('10001', 'Test GmbH')->willReturn($tickets);
|
||||
$odataGateway->method('getContactsForCustomer')->with('', 'Test GmbH')->willReturn([]);
|
||||
$odataGateway->method('getTicketLog')->willReturnCallback(static function (string $ticketNo): array {
|
||||
return [[
|
||||
'Entry_No' => 1,
|
||||
@@ -133,6 +134,53 @@ class TicketCommunicationServiceTest extends TestCase
|
||||
$this->assertContains('T12', $ticketNos);
|
||||
}
|
||||
|
||||
public function testLoadDebitorCommunicationFeedResolvesContactActorsOncePerDebitor(): void
|
||||
{
|
||||
$soapGateway = $this->createMock(BcSoapGateway::class);
|
||||
$soapGateway->method('getTicketCommunicationText')->willReturn([
|
||||
'ok' => false,
|
||||
'soap_used' => false,
|
||||
'return_value' => 400,
|
||||
'communication_text' => '',
|
||||
'message_entries' => '',
|
||||
'error' => 'SOAP unavailable',
|
||||
]);
|
||||
|
||||
$odataGateway = $this->createMock(BcODataGateway::class);
|
||||
$odataGateway->method('getTicketsForCustomer')->with('10001', 'Test GmbH')->willReturn([
|
||||
['No' => 'T1', 'Last_Activity_Date' => '2026-04-10T08:00:00Z', 'Created_On' => '2026-04-09T08:00:00Z'],
|
||||
['No' => 'T2', 'Last_Activity_Date' => '2026-04-09T08:00:00Z', 'Created_On' => '2026-04-08T08:00:00Z'],
|
||||
]);
|
||||
$odataGateway->expects($this->once())
|
||||
->method('getContactsForCustomer')
|
||||
->with('', 'Test GmbH')
|
||||
->willReturn([
|
||||
['No' => 'KT002967', 'Name' => 'Felix Schneider'],
|
||||
]);
|
||||
$odataGateway->expects($this->never())
|
||||
->method('getTicket');
|
||||
$odataGateway->method('getTicketLog')->willReturn([
|
||||
[
|
||||
'Entry_No' => 1,
|
||||
'Created_By' => 'KT002967',
|
||||
'Created_By_Type' => 'Contact',
|
||||
'Creation_Date' => '2026-04-03',
|
||||
'Creation_Time' => '07:59:00.000',
|
||||
'Type' => 'Nachricht',
|
||||
'State_Subtype' => '',
|
||||
'Record_ID' => 'Hallo',
|
||||
],
|
||||
]);
|
||||
|
||||
$service = new TicketCommunicationService($soapGateway, $odataGateway);
|
||||
$result = $service->loadDebitorCommunicationFeed('10001', 'Test GmbH', 2, 40, 250);
|
||||
|
||||
$this->assertTrue($result['ok']);
|
||||
$this->assertNotEmpty($result['entries']);
|
||||
$actors = array_unique(array_map(static fn (array $entry): string => (string) ($entry['actor'] ?? ''), $result['entries']));
|
||||
$this->assertSame(['Felix Schneider'], $actors);
|
||||
}
|
||||
|
||||
public function testLoadTicketCommunicationFeedRespectsEntryLimit(): void
|
||||
{
|
||||
$soapGateway = $this->createMock(BcSoapGateway::class);
|
||||
|
||||
@@ -1,4 +1,31 @@
|
||||
@layer pages {
|
||||
/* Helpdesk settings */
|
||||
.helpdesk-settings-panel {
|
||||
display: grid;
|
||||
gap: calc(var(--app-spacing) * 0.75);
|
||||
margin-top: calc(var(--app-spacing) * 0.75);
|
||||
}
|
||||
|
||||
.helpdesk-settings-test-result {
|
||||
margin-top: calc(var(--app-spacing) * 0.6);
|
||||
}
|
||||
|
||||
.helpdesk-settings-rule-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: calc(var(--app-spacing) * 0.65);
|
||||
}
|
||||
|
||||
.helpdesk-settings-rule-grid-full {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.helpdesk-settings-rule-grid {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
/* Clickable rows — shared between search results and ticket list */
|
||||
.app-clickable-row {
|
||||
cursor: pointer;
|
||||
@@ -15,21 +42,360 @@
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
/* Overview tab — header links */
|
||||
.app-stats-table-header .helpdesk-tab-link {
|
||||
float: right;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 400;
|
||||
text-decoration: none;
|
||||
/* Support dashboard */
|
||||
.helpdesk-support-metrics {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: calc(var(--app-spacing) * 0.75);
|
||||
margin-bottom: calc(var(--app-spacing) * 1);
|
||||
}
|
||||
|
||||
.app-stats-table-header .helpdesk-tab-link:hover {
|
||||
text-decoration: underline;
|
||||
.helpdesk-support-metric {
|
||||
margin: 0;
|
||||
padding: calc(var(--app-spacing) * 0.75) calc(var(--app-spacing) * 0.9);
|
||||
border: 1px solid var(--app-border, #d0d7de);
|
||||
border-radius: var(--app-border-radius);
|
||||
background: var(--app-card-background-color, #fff);
|
||||
}
|
||||
|
||||
/* KPI tile wrappers — ensure data-kpi spans don't break flex layout */
|
||||
.app-tiles > [data-kpi] {
|
||||
display: contents;
|
||||
.helpdesk-support-metric-label {
|
||||
margin: 0;
|
||||
font-size: var(--text-xs);
|
||||
line-height: 1.35;
|
||||
color: var(--app-muted, #6c757d);
|
||||
letter-spacing: var(--tracking-wide);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.helpdesk-kpi-info {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border-radius: 50%;
|
||||
border: 1px solid var(--app-border-color, #dee2e6);
|
||||
font-size: 9px;
|
||||
font-weight: var(--font-semibold, 600);
|
||||
font-style: italic;
|
||||
line-height: 1;
|
||||
color: var(--app-muted, #6c757d);
|
||||
text-transform: none;
|
||||
letter-spacing: 0;
|
||||
cursor: help;
|
||||
vertical-align: middle;
|
||||
margin-left: 0.15rem;
|
||||
transition: border-color 0.15s ease, color 0.15s ease;
|
||||
}
|
||||
|
||||
.helpdesk-kpi-info:hover,
|
||||
.helpdesk-kpi-info:focus-visible {
|
||||
border-color: var(--app-primary, #0d6efd);
|
||||
color: var(--app-primary, #0d6efd);
|
||||
}
|
||||
|
||||
.helpdesk-kpi-info::after {
|
||||
white-space: normal;
|
||||
max-width: 220px;
|
||||
text-align: left;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.helpdesk-support-metric-value {
|
||||
margin: 0.35rem 0 0;
|
||||
font-size: clamp(1.15rem, 1rem + 0.45vw, 1.45rem);
|
||||
line-height: 1.2;
|
||||
font-weight: var(--font-bold);
|
||||
color: var(--app-contrast, #1f2937);
|
||||
}
|
||||
|
||||
.helpdesk-support-metric-trend {
|
||||
margin: 0.3rem 0 0;
|
||||
font-size: var(--text-xs);
|
||||
line-height: 1.35;
|
||||
min-height: 1.1em;
|
||||
}
|
||||
|
||||
.helpdesk-kpi-trend-positive {
|
||||
color: var(--app-success, #198754);
|
||||
font-weight: var(--font-medium);
|
||||
}
|
||||
|
||||
.helpdesk-kpi-trend-negative {
|
||||
color: var(--app-danger, #dc3545);
|
||||
font-weight: var(--font-medium);
|
||||
}
|
||||
|
||||
.helpdesk-kpi-trend-neutral {
|
||||
color: var(--app-muted, #6c757d);
|
||||
}
|
||||
|
||||
.helpdesk-kpi-clickable {
|
||||
cursor: pointer;
|
||||
transition: border-color 0.15s ease, box-shadow 0.15s ease;
|
||||
}
|
||||
|
||||
.helpdesk-kpi-clickable:hover {
|
||||
border-color: var(--app-primary, #0d6efd);
|
||||
box-shadow: 0 1px 4px rgb(0 0 0 / 0.06);
|
||||
}
|
||||
|
||||
#support-escalation-wrapper {
|
||||
margin-top: calc(var(--app-spacing) * 0.75);
|
||||
}
|
||||
|
||||
.helpdesk-support-ticket-list {
|
||||
margin-top: calc(var(--app-spacing) * 1);
|
||||
}
|
||||
|
||||
.helpdesk-support-section-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: calc(var(--app-spacing) * 0.75);
|
||||
margin: 0 0 calc(var(--app-spacing) * 0.75);
|
||||
font-size: var(--text-base);
|
||||
font-weight: var(--font-semibold);
|
||||
color: var(--app-contrast, #1f2937);
|
||||
}
|
||||
|
||||
.helpdesk-support-section-title::after {
|
||||
content: "";
|
||||
flex: 1;
|
||||
height: 1px;
|
||||
background: var(--app-border, #dee2e6);
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
.helpdesk-support-metrics {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 560px) {
|
||||
.helpdesk-support-metrics {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
/* Sales dashboard — Stripe-style contract cards */
|
||||
.helpdesk-contract-cards {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(17rem, 1fr));
|
||||
gap: calc(var(--app-spacing) * 0.75);
|
||||
}
|
||||
|
||||
.helpdesk-contract-card {
|
||||
--card-accent: var(--app-muted, #6c757d);
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.35rem;
|
||||
padding: calc(var(--app-spacing) * 1) calc(var(--app-spacing) * 1.1);
|
||||
border: 1px solid var(--app-border, #d0d7de);
|
||||
border-left: 3px solid var(--card-accent);
|
||||
border-radius: var(--app-border-radius);
|
||||
background: var(--app-card-background-color, #fff);
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
font: inherit;
|
||||
color: inherit;
|
||||
width: 100%;
|
||||
transition: box-shadow 0.15s ease;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.helpdesk-contract-card:hover {
|
||||
box-shadow:
|
||||
0 1px 3px rgb(0 0 0 / 0.04),
|
||||
0 4px 12px rgb(0 0 0 / 0.06);
|
||||
}
|
||||
|
||||
.helpdesk-contract-card:focus-visible {
|
||||
outline: 2px solid var(--app-primary, #0d6efd);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
.helpdesk-contract-card.state-ended {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.helpdesk-contract-card.state-ended:hover {
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
/* Accent color palette — assigned per unique contract type */
|
||||
.helpdesk-contract-card.accent-0 {
|
||||
--card-accent: #e08614;
|
||||
}
|
||||
.helpdesk-contract-card.accent-1 {
|
||||
--card-accent: #16a34a;
|
||||
}
|
||||
.helpdesk-contract-card.accent-2 {
|
||||
--card-accent: #7c3aed;
|
||||
}
|
||||
.helpdesk-contract-card.accent-3 {
|
||||
--card-accent: #0ea5e9;
|
||||
}
|
||||
.helpdesk-contract-card.accent-4 {
|
||||
--card-accent: #0d6efd;
|
||||
}
|
||||
.helpdesk-contract-card.accent-5 {
|
||||
--card-accent: #d946ef;
|
||||
}
|
||||
.helpdesk-contract-card.accent-6 {
|
||||
--card-accent: #dc2626;
|
||||
}
|
||||
.helpdesk-contract-card.accent-7 {
|
||||
--card-accent: #059669;
|
||||
}
|
||||
|
||||
/* Type label — uppercase, accent-colored */
|
||||
.helpdesk-contract-card-type {
|
||||
font-size: var(--text-xs);
|
||||
font-weight: var(--font-semibold);
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
color: var(--card-accent);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
/* Amount — hero number */
|
||||
.helpdesk-contract-card-amount {
|
||||
font-size: clamp(1.35rem, 1.1rem + 0.5vw, 1.65rem);
|
||||
font-weight: var(--font-bold);
|
||||
color: var(--app-contrast, #1f2937);
|
||||
line-height: 1.15;
|
||||
letter-spacing: -0.01em;
|
||||
margin-top: 0.15rem;
|
||||
}
|
||||
|
||||
.helpdesk-contract-card-amount-muted {
|
||||
font-size: var(--text-base);
|
||||
font-weight: var(--font-semibold);
|
||||
color: var(--app-muted, #6c757d);
|
||||
}
|
||||
|
||||
.helpdesk-contract-card-period {
|
||||
font-size: var(--text-xs);
|
||||
font-weight: var(--font-normal);
|
||||
color: var(--app-muted, #6c757d);
|
||||
margin-top: -0.1rem;
|
||||
}
|
||||
|
||||
/* Status badge — top-right corner */
|
||||
.helpdesk-contract-card-status {
|
||||
position: absolute;
|
||||
top: calc(var(--app-spacing) * 0.65);
|
||||
right: calc(var(--app-spacing) * 0.75);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
font-size: var(--text-xs);
|
||||
color: var(--app-muted, #6c757d);
|
||||
}
|
||||
|
||||
.helpdesk-contract-card-dot {
|
||||
width: 0.45rem;
|
||||
height: 0.45rem;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
background: var(--app-muted, #6c757d);
|
||||
}
|
||||
|
||||
.helpdesk-contract-card.state-active .helpdesk-contract-card-dot {
|
||||
background: #16a34a;
|
||||
}
|
||||
|
||||
.helpdesk-contract-card.state-prep .helpdesk-contract-card-dot {
|
||||
background: #e08614;
|
||||
}
|
||||
|
||||
/* Contract detail dialog */
|
||||
.helpdesk-contract-dialog-article {
|
||||
max-height: min(80vh, 40rem);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.helpdesk-contract-dialog-article > header {
|
||||
position: relative;
|
||||
padding-inline-end: 2.5rem;
|
||||
}
|
||||
|
||||
.helpdesk-contract-dialog-article > header h2 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.helpdesk-contract-dialog-article > header h2 small {
|
||||
font-weight: var(--font-normal, 400);
|
||||
color: var(--app-muted, #6c757d);
|
||||
margin-left: 0.35rem;
|
||||
}
|
||||
|
||||
.helpdesk-contract-dialog-article > header .close {
|
||||
position: absolute;
|
||||
inset-inline-end: 1rem;
|
||||
inset-block-start: 1rem;
|
||||
float: none;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.helpdesk-contract-dialog-meta {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(10rem, 1fr));
|
||||
gap: calc(var(--app-spacing) * 0.65);
|
||||
padding-bottom: calc(var(--app-spacing) * 0.75);
|
||||
border-bottom: 1px solid var(--app-border, #dee2e6);
|
||||
margin-bottom: calc(var(--app-spacing) * 0.75);
|
||||
}
|
||||
|
||||
.helpdesk-contract-dialog-meta > span {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--app-contrast, #1f2937);
|
||||
}
|
||||
|
||||
.helpdesk-contract-dialog-meta strong {
|
||||
display: block;
|
||||
font-size: var(--text-xs);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: var(--tracking-wide);
|
||||
color: var(--app-muted, #6c757d);
|
||||
font-weight: var(--font-medium);
|
||||
margin-bottom: 0.15rem;
|
||||
}
|
||||
|
||||
.helpdesk-contract-dialog-section-title {
|
||||
margin: 0 0 calc(var(--app-spacing) * 0.35);
|
||||
font-size: var(--text-sm);
|
||||
font-weight: var(--font-semibold);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: var(--tracking-wide);
|
||||
color: var(--app-muted, #6c757d);
|
||||
}
|
||||
|
||||
.helpdesk-contract-dialog-lines {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.helpdesk-contract-dialog-lines table {
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
#sales-contacts-section {
|
||||
margin-top: calc(var(--app-spacing) * 1.5);
|
||||
}
|
||||
|
||||
#helpdesk-contacts-grid .gridjs-wrapper,
|
||||
#helpdesk-tickets-grid .gridjs-wrapper {
|
||||
min-height: auto;
|
||||
}
|
||||
|
||||
@media (max-width: 560px) {
|
||||
.helpdesk-contract-cards {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
/* Activity timeline */
|
||||
@@ -428,4 +794,236 @@
|
||||
min-width: 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* --- Controlling dashboard --- */
|
||||
|
||||
/* Segment control — radio + label (no button CSS bleed) */
|
||||
.helpdesk-segment-control {
|
||||
display: inline-flex;
|
||||
border: 1px solid var(--app-border, #dee2e6);
|
||||
border-radius: var(--app-radius, 0.375rem);
|
||||
overflow: hidden;
|
||||
margin-bottom: calc(var(--app-spacing) * 1);
|
||||
}
|
||||
|
||||
.helpdesk-segment-control input[type="radio"] {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.helpdesk-segment-control label {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0.375rem 0.875rem;
|
||||
font-size: var(--text-sm, 0.875rem);
|
||||
line-height: 1.4;
|
||||
color: var(--app-contrast, #1f2937);
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
transition: background-color 0.15s ease, color 0.15s ease;
|
||||
border-right: 1px solid var(--app-border, #dee2e6);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.helpdesk-segment-control label:last-of-type {
|
||||
border-right: none;
|
||||
}
|
||||
|
||||
.helpdesk-segment-control input[type="radio"]:checked + label {
|
||||
background: var(--app-primary, #0d6efd);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.helpdesk-segment-control input[type="radio"]:focus-visible + label {
|
||||
outline: 2px solid var(--app-primary, #0d6efd);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
.helpdesk-segment-control label:hover {
|
||||
background: var(--app-background-muted, #f8f9fa);
|
||||
}
|
||||
|
||||
.helpdesk-segment-control input[type="radio"]:checked + label:hover {
|
||||
background: var(--app-primary, #0d6efd);
|
||||
}
|
||||
|
||||
/* Horizontal bar chart */
|
||||
.helpdesk-controlling-chart {
|
||||
padding: calc(var(--app-spacing) * 0.75);
|
||||
}
|
||||
|
||||
.helpdesk-hbar-chart {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: calc(var(--app-spacing) * 0.5);
|
||||
}
|
||||
|
||||
.helpdesk-hbar-row {
|
||||
display: grid;
|
||||
grid-template-columns: 3.5rem 1fr;
|
||||
align-items: center;
|
||||
gap: calc(var(--app-spacing) * 0.5);
|
||||
}
|
||||
|
||||
.helpdesk-hbar-label {
|
||||
font-size: var(--text-sm, 0.875rem);
|
||||
color: var(--app-muted, #6c757d);
|
||||
text-align: right;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.helpdesk-hbar-track {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.helpdesk-hbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-width: 2px;
|
||||
height: 16px;
|
||||
border-radius: 0 4px 4px 0;
|
||||
transition: width 0.3s ease;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.helpdesk-hbar-created {
|
||||
background: hsl(0 65% 60% / 0.5);
|
||||
}
|
||||
|
||||
.helpdesk-hbar-closed {
|
||||
background: hsl(152 50% 48% / 0.5);
|
||||
}
|
||||
|
||||
.helpdesk-hbar-value {
|
||||
position: absolute;
|
||||
right: -0.25rem;
|
||||
transform: translateX(100%);
|
||||
font-size: var(--text-xs, 0.75rem);
|
||||
font-weight: var(--font-medium, 500);
|
||||
color: var(--app-muted, #6c757d);
|
||||
white-space: nowrap;
|
||||
line-height: 1;
|
||||
padding-left: 0.375rem;
|
||||
}
|
||||
|
||||
.helpdesk-hbar-legend {
|
||||
display: flex;
|
||||
gap: calc(var(--app-spacing) * 1);
|
||||
margin-top: calc(var(--app-spacing) * 0.75);
|
||||
font-size: var(--text-xs, 0.75rem);
|
||||
color: var(--app-muted, #6c757d);
|
||||
}
|
||||
|
||||
.helpdesk-hbar-legend-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
}
|
||||
|
||||
.helpdesk-hbar-legend-dot {
|
||||
display: inline-block;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.helpdesk-hbar-legend-dot.helpdesk-hbar-created {
|
||||
background: hsl(0 65% 60% / 0.5);
|
||||
}
|
||||
|
||||
.helpdesk-hbar-legend-dot.helpdesk-hbar-closed {
|
||||
background: hsl(152 50% 48% / 0.5);
|
||||
}
|
||||
|
||||
/* Key-value tables (efficiency, contract metrics) */
|
||||
.helpdesk-controlling-kv-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: var(--text-sm, 0.875rem);
|
||||
}
|
||||
|
||||
.helpdesk-controlling-kv-table td {
|
||||
padding: 0.375rem 0.5rem;
|
||||
border-bottom: 1px solid var(--app-border-subtle, #e9ecef);
|
||||
}
|
||||
|
||||
.helpdesk-controlling-kv-table td:last-child {
|
||||
text-align: right;
|
||||
font-weight: var(--font-medium, 500);
|
||||
color: var(--app-contrast, #1f2937);
|
||||
}
|
||||
|
||||
.helpdesk-controlling-kv-table td:first-child {
|
||||
color: var(--app-muted, #6c757d);
|
||||
}
|
||||
|
||||
/* Risk indicators */
|
||||
.helpdesk-controlling-risk-summary {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-bottom: 1px solid var(--app-border, #dee2e6);
|
||||
font-weight: var(--font-medium, 500);
|
||||
}
|
||||
|
||||
.helpdesk-controlling-risk-summary-count {
|
||||
font-size: var(--text-sm, 0.875rem);
|
||||
color: var(--app-muted, #6c757d);
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
.helpdesk-controlling-risk-list {
|
||||
display: grid;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.helpdesk-controlling-risk-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: calc(var(--app-spacing) * 0.5);
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-bottom: 1px solid var(--app-border-subtle, #e9ecef);
|
||||
}
|
||||
|
||||
.helpdesk-controlling-risk-row:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.helpdesk-controlling-risk-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.helpdesk-controlling-risk-dot-triggered {
|
||||
background: var(--app-danger, #dc3545);
|
||||
}
|
||||
|
||||
.helpdesk-controlling-risk-dot-ok {
|
||||
background: var(--app-success, #198754);
|
||||
}
|
||||
|
||||
.helpdesk-controlling-risk-label {
|
||||
flex: 1;
|
||||
font-size: var(--text-sm, 0.875rem);
|
||||
}
|
||||
|
||||
.helpdesk-controlling-risk-value {
|
||||
font-size: var(--text-sm, 0.875rem);
|
||||
color: var(--app-muted, #6c757d);
|
||||
text-align: right;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { renderEmptyState } from './helpdesk-empty-state.js';
|
||||
|
||||
function esc(str) {
|
||||
const d = document.createElement('div');
|
||||
d.textContent = String(str ?? '');
|
||||
@@ -72,9 +74,12 @@ export function renderCommunicationFeed(entries, options = {}) {
|
||||
|
||||
const list = Array.isArray(entries) ? entries : [];
|
||||
if (list.length === 0) {
|
||||
return `<div class="app-empty-state app-empty-state-compact">
|
||||
<p class="app-empty-state-message">${esc(t(emptyMessage))}</p>
|
||||
</div>`;
|
||||
return renderEmptyState({
|
||||
message: t(emptyMessage),
|
||||
size: 'compact',
|
||||
align: 'center',
|
||||
icon: false,
|
||||
});
|
||||
}
|
||||
|
||||
let html = '<ol class="helpdesk-comm-feed" role="list">';
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
32
modules/helpdesk/web/js/helpdesk-empty-state.js
Normal file
32
modules/helpdesk/web/js/helpdesk-empty-state.js
Normal file
@@ -0,0 +1,32 @@
|
||||
function esc(str) {
|
||||
const d = document.createElement('div');
|
||||
d.textContent = String(str ?? '');
|
||||
return d.innerHTML;
|
||||
}
|
||||
|
||||
const EMPTY_ICON_SVG = `
|
||||
<svg class="app-empty-state-icon" xmlns="http://www.w3.org/2000/svg" width="30" height="30" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<polyline points="22 12 16 12 14 15 10 15 8 12 2 12"></polyline>
|
||||
<path d="M5.45 5.11 2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z"></path>
|
||||
</svg>
|
||||
`;
|
||||
|
||||
export function renderEmptyState(options = {}) {
|
||||
const message = String(options.message || '').trim();
|
||||
if (!message) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const hint = String(options.hint || '').trim();
|
||||
const sizeRaw = String(options.size || 'compact').trim().toLowerCase();
|
||||
const size = ['default', 'compact', 'small'].includes(sizeRaw) ? sizeRaw : 'compact';
|
||||
const alignRaw = String(options.align || 'center').trim().toLowerCase();
|
||||
const align = ['center', 'left'].includes(alignRaw) ? alignRaw : 'center';
|
||||
const icon = options.icon === true;
|
||||
|
||||
return `<div class="app-empty-state" data-size="${esc(size)}" data-align="${esc(align)}">
|
||||
${icon ? EMPTY_ICON_SVG : ''}
|
||||
<p class="app-empty-state-message">${esc(message)}</p>
|
||||
${hint ? `<small class="app-empty-state-hint">${esc(hint)}</small>` : ''}
|
||||
</div>`;
|
||||
}
|
||||
@@ -3,6 +3,16 @@
|
||||
*/
|
||||
import { showAsyncFlash } from '/js/components/app-async-flash.js';
|
||||
|
||||
const settingsForm = document.getElementById('helpdesk-settings-form');
|
||||
|
||||
const getMessage = (key, fallback) => {
|
||||
if (!settingsForm || !settingsForm.dataset) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
return settingsForm.dataset[key] || fallback;
|
||||
};
|
||||
|
||||
// Toggle OAuth2 vs Basic Auth field visibility
|
||||
const authModeSelect = document.getElementById('auth_mode');
|
||||
const basicFields = document.getElementById('helpdesk-basic-auth-fields');
|
||||
@@ -12,9 +22,10 @@ const toggleAuthFields = () => {
|
||||
if (!authModeSelect || !basicFields || !oauth2Fields) {
|
||||
return;
|
||||
}
|
||||
|
||||
const isOAuth2 = authModeSelect.value === 'oauth2';
|
||||
basicFields.style.display = isOAuth2 ? 'none' : '';
|
||||
oauth2Fields.style.display = isOAuth2 ? '' : 'none';
|
||||
basicFields.toggleAttribute('hidden', isOAuth2);
|
||||
oauth2Fields.toggleAttribute('hidden', !isOAuth2);
|
||||
};
|
||||
|
||||
if (authModeSelect) {
|
||||
@@ -24,11 +35,43 @@ if (authModeSelect) {
|
||||
|
||||
// Connection test button
|
||||
const testButton = document.getElementById('helpdesk-test-connection-button');
|
||||
const testResult = document.getElementById('helpdesk-connection-test-result');
|
||||
|
||||
const setTestResult = (variant, message) => {
|
||||
if (!testResult) {
|
||||
return;
|
||||
}
|
||||
|
||||
testResult.textContent = message;
|
||||
testResult.dataset.variant = variant;
|
||||
testResult.hidden = !message;
|
||||
};
|
||||
|
||||
const setButtonLoadingState = (isLoading) => {
|
||||
if (!testButton) {
|
||||
return;
|
||||
}
|
||||
|
||||
const defaultLabel = testButton.dataset.labelDefault || getMessage('testDefaultLabel', 'Test connection');
|
||||
const loadingLabel = testButton.dataset.labelLoading || getMessage('testLoadingLabel', 'Testing...');
|
||||
|
||||
testButton.disabled = isLoading;
|
||||
testButton.toggleAttribute('aria-busy', isLoading);
|
||||
testButton.textContent = isLoading ? loadingLabel : defaultLabel;
|
||||
};
|
||||
|
||||
const parseJsonSafely = async (response) => {
|
||||
try {
|
||||
return await response.json();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
if (testButton) {
|
||||
testButton.addEventListener('click', async () => {
|
||||
testButton.disabled = true;
|
||||
testButton.setAttribute('aria-busy', 'true');
|
||||
setButtonLoadingState(true);
|
||||
setTestResult('info', getMessage('testLoadingLabel', 'Testing...'));
|
||||
|
||||
try {
|
||||
const csrfMeta = document.querySelector('meta[name="csrf-token"]');
|
||||
@@ -49,22 +92,35 @@ if (testButton) {
|
||||
},
|
||||
);
|
||||
|
||||
const data = await response.json();
|
||||
const data = await parseJsonSafely(response);
|
||||
const successMessage = getMessage('testSuccessMessage', 'Connection successful');
|
||||
const defaultErrorMessage = getMessage('testErrorMessage', 'Connection failed');
|
||||
|
||||
if (data.ok) {
|
||||
showAsyncFlash('success', data.message || 'Connection successful');
|
||||
if (response.ok && data?.ok) {
|
||||
const message = data.message || successMessage;
|
||||
setTestResult('success', message);
|
||||
showAsyncFlash('success', message);
|
||||
} else {
|
||||
let msg = data.error || 'Connection failed';
|
||||
if (data.debug?.http_code) {
|
||||
msg += ' (HTTP ' + data.debug.http_code + ')';
|
||||
let message = defaultErrorMessage;
|
||||
if (data?.error) {
|
||||
message = data.error;
|
||||
} else if (!response.ok) {
|
||||
message = `${defaultErrorMessage} (HTTP ${response.status})`;
|
||||
}
|
||||
showAsyncFlash('error', msg);
|
||||
|
||||
if (data?.debug?.http_code) {
|
||||
message += ` (HTTP ${data.debug.http_code})`;
|
||||
}
|
||||
|
||||
setTestResult('warning', message);
|
||||
showAsyncFlash('error', message);
|
||||
}
|
||||
} catch {
|
||||
showAsyncFlash('error', 'Connection failed');
|
||||
const message = getMessage('testErrorMessage', 'Connection failed');
|
||||
setTestResult('warning', message);
|
||||
showAsyncFlash('error', message);
|
||||
} finally {
|
||||
testButton.disabled = false;
|
||||
testButton.removeAttribute('aria-busy');
|
||||
setButtonLoadingState(false);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
renderCommunicationFeed,
|
||||
renderCommunicationHint,
|
||||
} from './helpdesk-communication.js';
|
||||
import { renderEmptyState } from './helpdesk-empty-state.js';
|
||||
|
||||
const container = document.querySelector('.app-details-container[data-ticket-no]');
|
||||
if (container) {
|
||||
@@ -95,9 +96,12 @@ function init(container) {
|
||||
|
||||
function renderTimeline(entries) {
|
||||
if (entries.length === 0) {
|
||||
return `<div class="app-empty-state app-empty-state-compact">
|
||||
<p class="app-empty-state-message">${esc(t('No activity found.'))}</p>
|
||||
</div>`;
|
||||
return renderEmptyState({
|
||||
message: t('No activity found.'),
|
||||
size: 'compact',
|
||||
align: 'center',
|
||||
icon: false,
|
||||
});
|
||||
}
|
||||
|
||||
let html = '<div class="helpdesk-timeline">';
|
||||
|
||||
@@ -92,7 +92,7 @@ trait ListContractFiles
|
||||
{
|
||||
return [
|
||||
'modules/addressbook/pages/address-book/index(default).phtml',
|
||||
'modules/helpdesk/pages/helpdesk/search(default).phtml',
|
||||
'modules/helpdesk/pages/helpdesk/index(default).phtml',
|
||||
'pages/search/index(default).phtml',
|
||||
'pages/admin/users/index(default).phtml',
|
||||
'pages/admin/tenants/index(default).phtml',
|
||||
@@ -115,7 +115,7 @@ trait ListContractFiles
|
||||
{
|
||||
return [
|
||||
'modules/addressbook/pages/address-book/index().php',
|
||||
'modules/helpdesk/pages/helpdesk/search().php',
|
||||
'modules/helpdesk/pages/helpdesk/index().php',
|
||||
'pages/search/index().php',
|
||||
'pages/admin/users/index().php',
|
||||
'pages/admin/tenants/index().php',
|
||||
@@ -138,7 +138,7 @@ trait ListContractFiles
|
||||
{
|
||||
return [
|
||||
'modules/addressbook/pages/address-book/index(default).phtml',
|
||||
'modules/helpdesk/pages/helpdesk/search(default).phtml',
|
||||
'modules/helpdesk/pages/helpdesk/index(default).phtml',
|
||||
'pages/admin/users/index(default).phtml',
|
||||
'pages/admin/tenants/index(default).phtml',
|
||||
'pages/admin/departments/index(default).phtml',
|
||||
|
||||
@@ -122,14 +122,14 @@
|
||||
.app-details-container .app-breadcrumb {
|
||||
padding-top: calc(var(--app-spacing) * 2);
|
||||
position: sticky;
|
||||
top: 16px;
|
||||
top: 49px;
|
||||
z-index: 10;
|
||||
background: var(--app-background-color);
|
||||
}
|
||||
|
||||
.app-details-container .app-details-titlebar {
|
||||
position: sticky;
|
||||
top: 64px;
|
||||
top: 93px;
|
||||
z-index: 10;
|
||||
background: var(--app-background-color);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user