From e897cc2c56dee7246e43f1f62a8de60a86c219ac Mon Sep 17 00:00:00 2001
From: fs
Date: Fri, 3 Apr 2026 16:45:41 +0200
Subject: [PATCH] feat(helpdesk): add ticket/debtor communication feed and chat
timeline UI
---
modules/helpdesk/i18n/default_de.json | 15 +
modules/helpdesk/i18n/default_en.json | 15 +
.../Helpdesk/HelpdeskContainerRegistrar.php | 13 +
.../Module/Helpdesk/Service/BcSoapGateway.php | 360 ++++++++
.../Service/TicketCommunicationService.php | 872 ++++++++++++++++++
modules/helpdesk/module.php | 2 +
.../pages/helpdesk/debitor(default).phtml | 34 +
.../helpdesk/debitor-communication-data().php | 53 ++
.../pages/helpdesk/debitor-summary-data().php | 23 +-
.../helpdesk/pages/helpdesk/ticket($id).php | 1 +
.../pages/helpdesk/ticket(default).phtml | 25 +-
.../helpdesk/ticket-communication-data().php | 51 +
.../Helpdesk/Service/BcSoapGatewayTest.php | 142 +++
.../TicketCommunicationServiceTest.php | 234 +++++
modules/helpdesk/web/css/helpdesk.css | 317 +++++++
.../helpdesk/web/js/helpdesk-communication.js | 128 +++
modules/helpdesk/web/js/helpdesk-detail.js | 52 ++
modules/helpdesk/web/js/helpdesk-ticket.js | 46 +
18 files changed, 2372 insertions(+), 11 deletions(-)
create mode 100644 modules/helpdesk/lib/Module/Helpdesk/Service/BcSoapGateway.php
create mode 100644 modules/helpdesk/lib/Module/Helpdesk/Service/TicketCommunicationService.php
create mode 100644 modules/helpdesk/pages/helpdesk/debitor-communication-data().php
create mode 100644 modules/helpdesk/pages/helpdesk/ticket-communication-data().php
create mode 100644 modules/helpdesk/tests/Module/Helpdesk/Service/BcSoapGatewayTest.php
create mode 100644 modules/helpdesk/tests/Module/Helpdesk/Service/TicketCommunicationServiceTest.php
create mode 100644 modules/helpdesk/web/js/helpdesk-communication.js
diff --git a/modules/helpdesk/i18n/default_de.json b/modules/helpdesk/i18n/default_de.json
index 2cba88e..4c6db51 100644
--- a/modules/helpdesk/i18n/default_de.json
+++ b/modules/helpdesk/i18n/default_de.json
@@ -104,10 +104,25 @@
"Status change": "Statusänderung",
"No activity found.": "Keine Aktivitäten gefunden.",
"Could not load activity log.": "Aktivitätsverlauf konnte nicht geladen werden.",
+ "Communication history": "Kommunikationsverlauf",
+ "No communication found.": "Keine Kommunikation gefunden.",
+ "Could not load communication history.": "Kommunikationsverlauf konnte nicht geladen werden.",
+ "Live communication unavailable, showing activity fallback.": "Live-Kommunikation nicht verfügbar, es werden Aktivitätsdaten als Fallback angezeigt.",
+ "Ticket communication": "Ticket-Kommunikation",
+ "Last 5 tickets": "Letzte 5 Tickets",
+ "Last 10 tickets": "Letzte 10 Tickets",
+ "Some ticket communications could not be loaded completely.": "Einige Ticket-Kommunikationen konnten nicht vollständig geladen werden.",
+ "Unknown user": "Unbekannter Benutzer",
+ "Unknown time": "Unbekannte Zeit",
+ "Source": "Quelle",
+ "SOAP": "SOAP",
+ "Activity fallback": "Aktivitäts-Fallback",
"sent a message": "hat eine Nachricht gesendet",
"changed status": "hat den Status geändert",
"Forwarded": "hat das Ticket weitergeleitet",
"attached a file": "hat eine Datei angehängt",
+ "logged an activity": "hat eine Aktivität erfasst",
+ "on": "am",
"Process": "Prozess",
"All": "Alle",
"Open": "Offen",
diff --git a/modules/helpdesk/i18n/default_en.json b/modules/helpdesk/i18n/default_en.json
index ca0cd9f..40124b6 100644
--- a/modules/helpdesk/i18n/default_en.json
+++ b/modules/helpdesk/i18n/default_en.json
@@ -104,10 +104,25 @@
"Status change": "Status change",
"No activity found.": "No activity found.",
"Could not load activity log.": "Could not load activity log.",
+ "Communication history": "Communication history",
+ "No communication found.": "No communication found.",
+ "Could not load communication history.": "Could not load communication history.",
+ "Live communication unavailable, showing activity fallback.": "Live communication unavailable, showing activity fallback.",
+ "Ticket communication": "Ticket communication",
+ "Last 5 tickets": "Last 5 tickets",
+ "Last 10 tickets": "Last 10 tickets",
+ "Some ticket communications could not be loaded completely.": "Some ticket communications could not be loaded completely.",
+ "Unknown user": "Unknown user",
+ "Unknown time": "Unknown time",
+ "Source": "Source",
+ "SOAP": "SOAP",
+ "Activity fallback": "Activity fallback",
"sent a message": "sent a message",
"changed status": "changed status",
"Forwarded": "forwarded the ticket",
"attached a file": "attached a file",
+ "logged an activity": "logged an activity",
+ "on": "on",
"Process": "Process",
"All": "All",
"Open": "Open",
diff --git a/modules/helpdesk/lib/Module/Helpdesk/HelpdeskContainerRegistrar.php b/modules/helpdesk/lib/Module/Helpdesk/HelpdeskContainerRegistrar.php
index 1032b31..2290404 100644
--- a/modules/helpdesk/lib/Module/Helpdesk/HelpdeskContainerRegistrar.php
+++ b/modules/helpdesk/lib/Module/Helpdesk/HelpdeskContainerRegistrar.php
@@ -6,11 +6,13 @@ use MintyPHP\App\AppContainer;
use MintyPHP\App\Container\ContainerRegistrar;
use MintyPHP\Http\SessionStoreInterface;
use MintyPHP\Module\Helpdesk\Service\BcODataGateway;
+use MintyPHP\Module\Helpdesk\Service\BcSoapGateway;
use MintyPHP\Module\Helpdesk\Service\DebitorDetailService;
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\TicketCommunicationService;
use MintyPHP\Service\Access\PermissionService;
use MintyPHP\Service\Settings\SettingServicesFactory;
use MintyPHP\Service\Settings\SettingsMetadataGateway;
@@ -34,6 +36,12 @@ final class HelpdeskContainerRegistrar implements ContainerRegistrar
$c->get(SessionStoreInterface::class)
));
+ $container->set(BcSoapGateway::class, static fn (AppContainer $c): BcSoapGateway => new BcSoapGateway(
+ $c->get(HelpdeskSettingsGateway::class),
+ $c->get(HelpdeskOAuthTokenService::class),
+ $c->get(SessionStoreInterface::class)
+ ));
+
$container->set(HelpdeskTokenRepository::class, static fn (AppContainer $c): HelpdeskTokenRepository => new HelpdeskTokenRepository(
$c->get(SettingServicesFactory::class)->createSettingsCryptoGateway()
));
@@ -52,5 +60,10 @@ final class HelpdeskContainerRegistrar implements ContainerRegistrar
$c->get(BcODataGateway::class),
$c->get(HelpdeskSettingsGateway::class)
));
+
+ $container->set(TicketCommunicationService::class, static fn (AppContainer $c): TicketCommunicationService => new TicketCommunicationService(
+ $c->get(BcSoapGateway::class),
+ $c->get(BcODataGateway::class)
+ ));
}
}
diff --git a/modules/helpdesk/lib/Module/Helpdesk/Service/BcSoapGateway.php b/modules/helpdesk/lib/Module/Helpdesk/Service/BcSoapGateway.php
new file mode 100644
index 0000000..43050cf
--- /dev/null
+++ b/modules/helpdesk/lib/Module/Helpdesk/Service/BcSoapGateway.php
@@ -0,0 +1,360 @@
+ false,
+ 'soap_used' => false,
+ 'return_value' => 0,
+ 'communication_text' => '',
+ 'message_entries' => '',
+ 'error' => 'Missing ticket number',
+ ];
+ }
+
+ if (!$this->settingsGateway->isConfigured()) {
+ return [
+ 'ok' => false,
+ 'soap_used' => false,
+ 'return_value' => 0,
+ 'communication_text' => '',
+ 'message_entries' => '',
+ 'error' => 'BC connection not configured',
+ ];
+ }
+
+ $endpoint = $this->buildSoapEndpoint();
+ if ($endpoint === null) {
+ return [
+ 'ok' => false,
+ 'soap_used' => false,
+ 'return_value' => 0,
+ 'communication_text' => '',
+ 'message_entries' => '',
+ 'error' => 'Could not derive SOAP endpoint from BC URL',
+ ];
+ }
+
+ $requestBody = $this->buildGetTicketCommunicationTextEnvelope($ticketNo, $messageEntries);
+ $request = $this->buildRequestContext($endpoint, $requestBody);
+ if (isset($request['error'])) {
+ return [
+ 'ok' => false,
+ 'soap_used' => false,
+ 'return_value' => 0,
+ 'communication_text' => '',
+ 'message_entries' => '',
+ 'error' => (string) $request['error'],
+ ];
+ }
+
+ $response = $this->sendSoapRequest(
+ $endpoint,
+ self::SOAP_ACTION_GET_TICKET_COMMUNICATION_TEXT,
+ $requestBody,
+ (array) ($request['headers'] ?? []),
+ (string) ($request['basic_user'] ?? ''),
+ (string) ($request['basic_password'] ?? '')
+ );
+
+ $httpCode = (int) ($response['http_code'] ?? 0);
+ $rawBody = (string) ($response['body'] ?? '');
+ if ($httpCode < 200 || $httpCode >= 300) {
+ $errorSnippet = trim((string) ($response['error'] ?? ''));
+ if ($errorSnippet === '' && $rawBody !== '') {
+ $errorSnippet = mb_substr($rawBody, 0, 250);
+ }
+
+ return [
+ 'ok' => false,
+ 'soap_used' => false,
+ 'return_value' => 0,
+ 'communication_text' => '',
+ 'message_entries' => '',
+ 'error' => 'BC SOAP request failed: HTTP ' . $httpCode . ($errorSnippet !== '' ? ' - ' . $errorSnippet : ''),
+ ];
+ }
+
+ $parsed = $this->parseGetTicketCommunicationTextResponse($rawBody);
+ if (!($parsed['ok'] ?? false)) {
+ return [
+ 'ok' => false,
+ 'soap_used' => false,
+ 'return_value' => 0,
+ 'communication_text' => '',
+ 'message_entries' => '',
+ 'error' => (string) ($parsed['error'] ?? 'Could not parse SOAP response'),
+ ];
+ }
+
+ $returnValue = (int) ($parsed['return_value'] ?? 0);
+ if ($returnValue >= 400) {
+ return [
+ 'ok' => false,
+ 'soap_used' => false,
+ 'return_value' => $returnValue,
+ 'communication_text' => (string) ($parsed['communication_text'] ?? ''),
+ 'message_entries' => (string) ($parsed['message_entries'] ?? ''),
+ 'error' => 'BC SOAP returned error code ' . $returnValue,
+ ];
+ }
+
+ return [
+ 'ok' => true,
+ 'soap_used' => true,
+ 'return_value' => $returnValue,
+ 'communication_text' => (string) ($parsed['communication_text'] ?? ''),
+ 'message_entries' => (string) ($parsed['message_entries'] ?? ''),
+ ];
+ }
+
+ private function buildGetTicketCommunicationTextEnvelope(string $ticketNo, string $messageEntries): string
+ {
+ $escapedTicketNo = htmlspecialchars($ticketNo, ENT_XML1 | ENT_QUOTES, 'UTF-8');
+ $normalizedMessageEntries = trim($messageEntries);
+ if ($normalizedMessageEntries === '') {
+ $normalizedMessageEntries = '0';
+ }
+ $escapedMessageEntries = htmlspecialchars($normalizedMessageEntries, ENT_XML1 | ENT_QUOTES, 'UTF-8');
+
+ return <<
+
+
+
+ {$escapedTicketNo}
+
+ {$escapedMessageEntries}
+
+
+
+XML;
+ }
+
+ /**
+ * @return array{headers?: array, basic_user?: string, basic_password?: string, error?: string}
+ */
+ private function buildRequestContext(string $endpoint, string $requestBody): array
+ {
+ $headers = [
+ 'Content-Type: text/xml; charset=utf-8',
+ 'Accept: text/xml,application/xml',
+ 'SOAPAction: "' . self::SOAP_ACTION_GET_TICKET_COMMUNICATION_TEXT . '"',
+ 'Content-Length: ' . strlen($requestBody),
+ ];
+
+ $authMode = $this->settingsGateway->getAuthMode();
+ if ($authMode === HelpdeskSettingsGateway::AUTH_MODE_BASIC) {
+ return [
+ 'headers' => $headers,
+ 'basic_user' => $this->settingsGateway->getBasicUser() ?? '',
+ 'basic_password' => $this->settingsGateway->getBasicPassword() ?? '',
+ ];
+ }
+
+ if ($authMode !== HelpdeskSettingsGateway::AUTH_MODE_OAUTH2) {
+ return ['headers' => $headers];
+ }
+
+ $tenantId = $this->resolveCurrentTenantId();
+ if ($tenantId <= 0) {
+ return ['error' => 'OAuth2 mode requires active tenant context'];
+ }
+
+ $audience = $this->buildOAuthAudienceFromEndpoint($endpoint);
+ if ($audience === null) {
+ return ['error' => 'Could not build OAuth audience for SOAP endpoint'];
+ }
+
+ $token = $this->oauthTokenService->getAccessToken($tenantId, $audience);
+ if ($token === null || trim($token) === '') {
+ return ['error' => 'Could not obtain OAuth2 access token for SOAP request'];
+ }
+
+ $headers[] = 'Authorization: Bearer ' . $token;
+
+ return ['headers' => $headers];
+ }
+
+ /**
+ * @return array{ok: bool, return_value?: int, communication_text?: string, message_entries?: string, error?: string}
+ */
+ private function parseGetTicketCommunicationTextResponse(string $body): array
+ {
+ $body = trim($body);
+ if ($body === '') {
+ return ['ok' => false, 'error' => 'Empty SOAP response'];
+ }
+
+ $dom = new \DOMDocument();
+ $previousLibxmlState = libxml_use_internal_errors(true);
+ $loaded = $dom->loadXML($body);
+ libxml_clear_errors();
+ libxml_use_internal_errors($previousLibxmlState);
+ if (!$loaded) {
+ return ['ok' => false, 'error' => 'Invalid SOAP XML response'];
+ }
+
+ $xpath = new \DOMXPath($dom);
+ $faultNode = $xpath->query("//*[local-name()='Fault']/*[local-name()='faultstring']")->item(0);
+ if ($faultNode instanceof \DOMNode) {
+ return ['ok' => false, 'error' => trim((string) $faultNode->textContent) ?: 'SOAP fault'];
+ }
+
+ $returnNode = $xpath->query("//*[local-name()='return_value']")->item(0);
+ $communicationNode = $xpath->query("//*[local-name()='communicationText']")->item(0);
+ $messageEntriesNode = $xpath->query("//*[local-name()='messageEntries']")->item(0);
+
+ return [
+ 'ok' => true,
+ 'return_value' => $returnNode instanceof \DOMNode && is_numeric(trim((string) $returnNode->textContent))
+ ? (int) trim((string) $returnNode->textContent)
+ : 0,
+ 'communication_text' => $communicationNode instanceof \DOMNode ? (string) $communicationNode->textContent : '',
+ 'message_entries' => $messageEntriesNode instanceof \DOMNode ? (string) $messageEntriesNode->textContent : '',
+ ];
+ }
+
+ protected function sendSoapRequest(
+ string $endpoint,
+ string $soapAction,
+ string $requestBody,
+ array $headers,
+ string $basicUser,
+ string $basicPassword
+ ): array {
+ $ch = curl_init();
+ if ($ch === false) {
+ return ['http_code' => 0, 'body' => '', 'error' => 'curl_init failed'];
+ }
+
+ curl_setopt_array($ch, [
+ CURLOPT_URL => $endpoint,
+ CURLOPT_RETURNTRANSFER => true,
+ CURLOPT_CONNECTTIMEOUT => self::CONNECT_TIMEOUT,
+ CURLOPT_TIMEOUT => self::REQUEST_TIMEOUT,
+ CURLOPT_SSL_VERIFYPEER => true,
+ CURLOPT_SSL_VERIFYHOST => 2,
+ CURLOPT_POST => true,
+ CURLOPT_POSTFIELDS => $requestBody,
+ CURLOPT_HTTPHEADER => $headers,
+ ]);
+
+ if ($basicUser !== '' || $basicPassword !== '') {
+ curl_setopt($ch, CURLOPT_USERPWD, $basicUser . ':' . $basicPassword);
+ curl_setopt($ch, CURLOPT_HTTPAUTH, CURLAUTH_BASIC);
+ }
+
+ $responseBody = curl_exec($ch);
+ $httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
+ $error = curl_error($ch);
+ unset($ch);
+
+ return [
+ 'http_code' => $httpCode,
+ 'body' => is_string($responseBody) ? $responseBody : '',
+ 'error' => $error,
+ ];
+ }
+
+ private function buildSoapEndpoint(): ?string
+ {
+ $baseUrl = trim($this->settingsGateway->getODataBaseUrl());
+ if ($baseUrl === '') {
+ return null;
+ }
+
+ $parsed = parse_url($baseUrl);
+ if (!is_array($parsed)) {
+ return null;
+ }
+
+ $scheme = strtolower((string) ($parsed['scheme'] ?? 'https'));
+ $host = trim((string) ($parsed['host'] ?? ''));
+ if ($host === '') {
+ return null;
+ }
+
+ $odataPath = '/' . ltrim((string) ($parsed['path'] ?? ''), '/');
+ $instancePath = (string) preg_replace('#/ODataV4(?:/.*)?$#i', '', $odataPath);
+ $instancePath = rtrim($instancePath !== '' ? $instancePath : '/BusinessCentral', '/');
+ if ($instancePath === '') {
+ $instancePath = '/BusinessCentral';
+ }
+
+ $odataPort = (int) ($parsed['port'] ?? 0);
+ $soapPort = $odataPort === 7048 ? 7047 : $odataPort;
+
+ $authority = $host;
+ if ($soapPort > 0 && !(($scheme === 'https' && $soapPort === 443) || ($scheme === 'http' && $soapPort === 80))) {
+ $authority .= ':' . $soapPort;
+ }
+
+ $company = rawurlencode($this->settingsGateway->getCompanyName());
+
+ return $scheme . '://' . $authority . $instancePath . '/WS/' . $company . '/Codeunit/' . self::CODEUNIT_NAME;
+ }
+
+ private function buildOAuthAudienceFromEndpoint(string $endpoint): ?string
+ {
+ $parsed = parse_url($endpoint);
+ if (!is_array($parsed)) {
+ return null;
+ }
+
+ $scheme = strtolower((string) ($parsed['scheme'] ?? 'https'));
+ $host = trim((string) ($parsed['host'] ?? ''));
+ if ($host === '') {
+ return null;
+ }
+
+ $port = (int) ($parsed['port'] ?? 0);
+ $authority = $host;
+ if ($port > 0 && !(($scheme === 'https' && $port === 443) || ($scheme === 'http' && $port === 80))) {
+ $authority .= ':' . $port;
+ }
+
+ return $scheme . '://' . $authority;
+ }
+
+ private function resolveCurrentTenantId(): int
+ {
+ $session = $this->sessionStore->all();
+ $tenantId = (int) (($session['current_tenant']['id'] ?? 0));
+
+ return $tenantId > 0 ? $tenantId : 0;
+ }
+}
diff --git a/modules/helpdesk/lib/Module/Helpdesk/Service/TicketCommunicationService.php b/modules/helpdesk/lib/Module/Helpdesk/Service/TicketCommunicationService.php
new file mode 100644
index 0000000..9d514d5
--- /dev/null
+++ b/modules/helpdesk/lib/Module/Helpdesk/Service/TicketCommunicationService.php
@@ -0,0 +1,872 @@
+>,
+ * meta?: array,
+ * error?: string
+ * }
+ */
+ public function loadTicketCommunicationFeed(string $ticketNo, int $maxEntries = 40): array
+ {
+ $ticketNo = trim($ticketNo);
+ if ($ticketNo === '') {
+ return ['ok' => false, 'error' => 'Missing ticket number'];
+ }
+
+ $maxEntries = max(1, min(250, $maxEntries));
+
+ $ticketLog = [];
+ $odataError = '';
+ $odataEntries = [];
+ try {
+ $ticketLog = $this->bcODataGateway->getTicketLog($ticketNo);
+ } catch (\Throwable $e) {
+ $odataError = $e->getMessage();
+ }
+
+ $messageEntries = $this->buildSoapMessageEntriesFromTicketLog($ticketLog);
+ $soapResult = $this->bcSoapGateway->getTicketCommunicationText($ticketNo, $messageEntries);
+ $soapError = (string) ($soapResult['error'] ?? '');
+ $soapUsed = (bool) ($soapResult['soap_used'] ?? false);
+ $soapEntries = [];
+ $soapMessageMap = [];
+ if (($soapResult['ok'] ?? false) === true) {
+ $soapEntries = $this->parseSoapEntries(
+ $ticketNo,
+ (string) ($soapResult['communication_text'] ?? ''),
+ (string) ($soapResult['message_entries'] ?? '')
+ );
+ $soapMessageMap = $this->extractSoapMessageMap(
+ (string) ($soapResult['communication_text'] ?? ''),
+ (string) ($soapResult['message_entries'] ?? '')
+ );
+ }
+
+ if ($ticketLog !== []) {
+ $odataEntries = $this->mapODataEntries($ticketNo, $ticketLog, $soapMessageMap);
+ }
+
+ $actorNameMap = $this->resolveContactActorNames($ticketNo, $odataEntries, $soapEntries);
+ if ($actorNameMap !== []) {
+ $odataEntries = $this->applyActorNameMap($odataEntries, $actorNameMap);
+ $soapEntries = $this->applyActorNameMap($soapEntries, $actorNameMap);
+ }
+
+ $merged = $this->mergeEntries($odataEntries, $soapEntries, $maxEntries);
+ $fallbackUsed = $this->hasFallbackOnlyEntries($odataEntries) && $soapEntries === [];
+
+ if ($merged === [] && $soapError !== '' && $odataError !== '') {
+ return [
+ 'ok' => false,
+ 'error' => 'Could not load communication feed',
+ 'meta' => [
+ 'ticket_no' => $ticketNo,
+ 'soap_used' => false,
+ 'soap_error' => $soapError,
+ 'fallback_used' => false,
+ ],
+ ];
+ }
+
+ return [
+ 'ok' => true,
+ 'entries' => $merged,
+ 'meta' => [
+ 'ticket_no' => $ticketNo,
+ 'soap_used' => $soapUsed,
+ 'soap_error' => $soapError,
+ 'fallback_used' => $fallbackUsed,
+ ],
+ ];
+ }
+
+ /**
+ * @return array{
+ * ok: bool,
+ * entries?: array>,
+ * meta?: array,
+ * error?: string
+ * }
+ */
+ public function loadDebitorCommunicationFeed(
+ string $customerNo,
+ string $customerName,
+ int $ticketsLimit = 10,
+ int $maxEntriesPerTicket = 40,
+ int $maxEntriesTotal = 250
+ ): array {
+ $customerNo = trim($customerNo);
+ $customerName = trim($customerName);
+ if ($customerNo === '' || $customerName === '') {
+ return ['ok' => false, 'error' => 'Missing customer number or name'];
+ }
+
+ $ticketsLimit = max(1, min(25, $ticketsLimit));
+ $maxEntriesPerTicket = max(1, min(80, $maxEntriesPerTicket));
+ $maxEntriesTotal = max(1, min(500, $maxEntriesTotal));
+
+ try {
+ $tickets = $this->bcODataGateway->getTicketsForCustomer($customerNo, $customerName);
+ } catch (\Throwable) {
+ return ['ok' => false, 'error' => 'Could not load tickets for customer'];
+ }
+
+ if ($tickets === []) {
+ return [
+ 'ok' => true,
+ 'entries' => [],
+ 'meta' => [
+ 'customer_no' => $customerNo,
+ 'tickets_considered' => 0,
+ 'tickets_with_data' => 0,
+ 'soap_partial_failures' => 0,
+ ],
+ ];
+ }
+
+ usort($tickets, function (array $a, array $b): int {
+ $sa = $this->ticketSortStamp($a);
+ $sb = $this->ticketSortStamp($b);
+
+ return $sb <=> $sa;
+ });
+
+ $ticketsToProcess = [];
+ foreach ($tickets as $ticket) {
+ $ticketNo = trim((string) ($ticket['No'] ?? ''));
+ if ($ticketNo === '') {
+ continue;
+ }
+ $ticketsToProcess[$ticketNo] = true;
+ if (count($ticketsToProcess) >= $ticketsLimit) {
+ break;
+ }
+ }
+
+ $allEntries = [];
+ $ticketsWithData = 0;
+ $soapPartialFailures = 0;
+
+ foreach (array_keys($ticketsToProcess) as $ticketNo) {
+ $feed = $this->loadTicketCommunicationFeed($ticketNo, $maxEntriesPerTicket);
+ $ticketEntries = [];
+ if ($feed['ok'] ?? false) {
+ $ticketEntries = is_array($feed['entries'] ?? null) ? $feed['entries'] : [];
+ }
+
+ if ($ticketEntries !== []) {
+ $ticketsWithData++;
+ foreach ($ticketEntries as $entry) {
+ $allEntries[] = $entry;
+ }
+ }
+
+ $meta = is_array($feed['meta'] ?? null) ? $feed['meta'] : [];
+ $soapUsed = (bool) ($meta['soap_used'] ?? false);
+ $soapError = trim((string) ($meta['soap_error'] ?? ''));
+ if (!$soapUsed && $soapError !== '') {
+ $soapPartialFailures++;
+ }
+ }
+
+ $entries = $this->mergeEntries($allEntries, [], $maxEntriesTotal);
+
+ return [
+ 'ok' => true,
+ 'entries' => $entries,
+ 'meta' => [
+ 'customer_no' => $customerNo,
+ 'tickets_considered' => count($ticketsToProcess),
+ 'tickets_with_data' => $ticketsWithData,
+ 'soap_partial_failures' => $soapPartialFailures,
+ ],
+ ];
+ }
+
+ /**
+ * @param array> $ticketLog
+ * @param array $soapMessageMap
+ * @return array>
+ */
+ private function mapODataEntries(string $ticketNo, array $ticketLog, array $soapMessageMap = []): array
+ {
+ $entries = [];
+ foreach ($ticketLog as $row) {
+ $type = trim((string) ($row['Type'] ?? ''));
+ $stateSubtype = trim((string) ($row['State_Subtype'] ?? ''));
+ $recordId = trim((string) ($row['Record_ID'] ?? ''));
+ $actor = trim((string) ($row['Created_By'] ?? ''));
+ $createdByType = trim((string) ($row['Created_By_Type'] ?? ''));
+ $date = trim((string) ($row['Creation_Date'] ?? ''));
+ $time = trim((string) ($row['Creation_Time'] ?? ''));
+ $entryNo = $this->normalizeEntryNo($row['Entry_No'] ?? null);
+
+ $timestampIso = $this->combineDateTimeToIso($date, $time);
+ $soapMessage = $entryNo !== '' ? (string) ($soapMessageMap[$entryNo] ?? '') : '';
+ $message = $soapMessage !== ''
+ ? $soapMessage
+ : $this->buildODataMessage($type, $stateSubtype, $recordId);
+ [$normalizedType, $typeVariant] = $this->normalizeType($type, $message);
+
+ $entry = [
+ 'entry_id' => 'odata-' . substr(hash('sha256', $ticketNo . '|' . ($row['Entry_No'] ?? '') . '|' . $timestampIso . '|' . $actor . '|' . $normalizedType . '|' . $message), 0, 20),
+ 'ticket_no' => $ticketNo,
+ 'timestamp_iso' => $timestampIso,
+ 'date' => $date,
+ 'time' => $this->normalizeTime($time),
+ 'actor' => $actor,
+ 'actor_role' => $this->normalizeActorRole($createdByType, $actor),
+ 'type' => $normalizedType,
+ 'type_variant' => $typeVariant,
+ 'state_subtype' => $stateSubtype,
+ 'message' => $message,
+ 'source' => $soapMessage !== '' ? 'soap' : 'odata',
+ ];
+
+ $entries[] = $entry;
+ }
+
+ return $entries;
+ }
+
+ /**
+ * @return array>
+ */
+ private function parseSoapEntries(string $ticketNo, string $communicationText, string $messageEntries): array
+ {
+ $entries = [];
+ $candidates = [];
+ if (!$this->isSoapPayloadEmpty($messageEntries)) {
+ $candidates[] = $messageEntries;
+ }
+ if (!$this->isSoapPayloadEmpty($communicationText)) {
+ $candidates[] = $communicationText;
+ }
+
+ foreach ($candidates as $candidate) {
+ foreach ($this->extractEntriesFromPayload($ticketNo, $candidate) as $entry) {
+ $entries[] = $entry;
+ }
+ }
+
+ return $this->mergeEntries($entries, [], 200);
+ }
+
+ /**
+ * @param array> $ticketLog
+ */
+ private function buildSoapMessageEntriesFromTicketLog(array $ticketLog): string
+ {
+ $entryNos = [];
+ foreach ($ticketLog as $row) {
+ $type = trim((string) ($row['Type'] ?? ''));
+ [$normalizedType, $typeVariant] = $this->normalizeType($type, '');
+ if ($typeVariant !== 'message' && !in_array($normalizedType, ['Message', 'Nachricht'], true)) {
+ continue;
+ }
+
+ $entryNo = $this->normalizeEntryNo($row['Entry_No'] ?? null);
+ if ($entryNo !== '') {
+ $entryNos[$entryNo] = true;
+ }
+ }
+
+ if ($entryNos === []) {
+ return '';
+ }
+
+ return implode(';', array_keys($entryNos)) . ';';
+ }
+
+ /**
+ * @return array
+ */
+ private function extractSoapMessageMap(string $communicationText, string $messageEntries): array
+ {
+ $sources = [$communicationText, $messageEntries];
+ foreach ($sources as $payload) {
+ $payload = trim($payload);
+ if ($payload === '' || $this->isSoapPayloadEmpty($payload)) {
+ continue;
+ }
+
+ $decoded = json_decode($payload, true);
+ if (!is_array($decoded) || !array_is_list($decoded)) {
+ continue;
+ }
+
+ $map = [];
+ foreach ($decoded as $row) {
+ if (!is_array($row)) {
+ continue;
+ }
+
+ $entryNo = $this->normalizeEntryNo($row['EntryNo'] ?? $row['entryNo'] ?? $row['entry_no'] ?? null);
+ if ($entryNo === '') {
+ continue;
+ }
+
+ $message = $this->normalizeSoapMessageText((string) ($row['DataBlob'] ?? $row['dataBlob'] ?? $row['message'] ?? $row['text'] ?? ''));
+ if ($message === '') {
+ continue;
+ }
+
+ $map[$entryNo] = $message;
+ }
+
+ if ($map !== []) {
+ return $map;
+ }
+ }
+
+ return [];
+ }
+
+ /**
+ * @return array>
+ */
+ private function extractEntriesFromPayload(string $ticketNo, string $payload): array
+ {
+ $payload = trim($payload);
+ if ($payload === '' || $this->isSoapPayloadEmpty($payload)) {
+ return [];
+ }
+
+ $jsonDecoded = json_decode($payload, true);
+ if (is_array($jsonDecoded) && array_is_list($jsonDecoded)) {
+ $entries = [];
+ foreach ($jsonDecoded as $row) {
+ if (!is_array($row)) {
+ continue;
+ }
+ $message = $this->normalizeSoapMessageText((string) ($row['message'] ?? $row['text'] ?? $row['DataBlob'] ?? $row['dataBlob'] ?? ''));
+ if ($message === '') {
+ continue;
+ }
+ $timestampIso = trim((string) ($row['timestamp'] ?? $row['created_at'] ?? ''));
+ $actor = trim((string) ($row['actor'] ?? $row['user'] ?? ''));
+ $createdByType = trim((string) ($row['created_by_type'] ?? $row['Created_By_Type'] ?? ''));
+ $stateSubtype = trim((string) ($row['state_subtype'] ?? ''));
+ [$type, $variant] = $this->normalizeType((string) ($row['type'] ?? 'Nachricht'), $message);
+ [$date, $time] = $this->splitTimestamp($timestampIso);
+ $entry = $this->buildSoapEntry(
+ $ticketNo,
+ $timestampIso,
+ $date,
+ $time,
+ $actor,
+ $this->normalizeActorRole($createdByType, $actor),
+ $type,
+ $variant,
+ $stateSubtype,
+ $message
+ );
+ $entryNo = $this->normalizeEntryNo($row['EntryNo'] ?? $row['entryNo'] ?? $row['entry_no'] ?? null);
+ if ($entryNo !== '') {
+ $entry['entry_id'] = 'soap-' . substr(hash('sha256', $ticketNo . '|' . $entryNo . '|' . $timestampIso . '|' . $actor . '|' . $type . '|' . $message), 0, 20);
+ }
+ $entries[] = $entry;
+ }
+ if ($entries !== []) {
+ return $entries;
+ }
+ }
+
+ $normalized = str_replace(["\r\n", "\r"], "\n", $payload);
+ $normalized = preg_replace('#
#i', "\n", $normalized) ?? $normalized;
+ $normalized = strip_tags($normalized);
+ $normalized = html_entity_decode($normalized, ENT_QUOTES | ENT_HTML5, 'UTF-8');
+
+ $lines = preg_split('/\n+/', $normalized) ?: [];
+ $lines = array_values(array_filter(array_map(static fn (string $line): string => trim($line), $lines), static fn (string $line): bool => $line !== ''));
+ if ($lines === []) {
+ return [];
+ }
+
+ $entries = [];
+ foreach ($lines as $line) {
+ $entries[] = $this->parseSoapLine($ticketNo, $line);
+ }
+
+ return $entries;
+ }
+
+ private function isSoapPayloadEmpty(string $payload): bool
+ {
+ $normalized = trim(html_entity_decode($payload, ENT_QUOTES | ENT_HTML5, 'UTF-8'));
+ if ($normalized === '') {
+ return true;
+ }
+
+ return in_array($normalized, ['0', '[]', '0[]', 'null', '{}'], true);
+ }
+
+ /**
+ * @return array
+ */
+ private function parseSoapLine(string $ticketNo, string $line): array
+ {
+ $working = trim($line);
+ $timestampIso = '';
+ $date = '';
+ $time = '';
+
+ 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]) : '';
+ $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) {
+ $date = $m[1];
+ $time = $this->normalizeTime($m[2]);
+ $timestampIso = $this->convertEuropeanDateTimeToIso($date, $time);
+ $working = trim($m[3]);
+ }
+
+ $actor = '';
+ $message = $working;
+ if (preg_match('/^([^:]{2,80}):\s*(.+)$/u', $working, $m) === 1) {
+ $actor = trim($m[1]);
+ $message = trim($m[2]);
+ }
+
+ [$type, $variant] = $this->normalizeType('Nachricht', $message);
+ $stateSubtype = '';
+
+ return $this->buildSoapEntry(
+ $ticketNo,
+ $timestampIso,
+ $date,
+ $time,
+ $actor,
+ $this->normalizeActorRole('', $actor),
+ $type,
+ $variant,
+ $stateSubtype,
+ $message
+ );
+ }
+
+ /**
+ * @return array
+ */
+ private function buildSoapEntry(
+ string $ticketNo,
+ string $timestampIso,
+ string $date,
+ string $time,
+ string $actor,
+ string $actorRole,
+ string $type,
+ string $variant,
+ string $stateSubtype,
+ string $message
+ ): array {
+ return [
+ 'entry_id' => 'soap-' . substr(hash('sha256', $ticketNo . '|' . $timestampIso . '|' . $actor . '|' . $type . '|' . $message), 0, 20),
+ 'ticket_no' => $ticketNo,
+ 'timestamp_iso' => $timestampIso,
+ 'date' => $date,
+ 'time' => $time,
+ 'actor' => $actor,
+ 'actor_role' => $actorRole,
+ 'type' => $type,
+ 'type_variant' => $variant,
+ 'state_subtype' => $stateSubtype,
+ 'message' => $message,
+ 'source' => 'soap',
+ ];
+ }
+
+ /**
+ * @param array> $primary
+ * @param array> $secondary
+ * @return array>
+ */
+ private function mergeEntries(array $primary, array $secondary, int $maxEntries): array
+ {
+ $combined = [...$primary, ...$secondary];
+ if ($combined === []) {
+ return [];
+ }
+
+ usort($combined, function (array $a, array $b): int {
+ $ta = trim((string) ($a['timestamp_iso'] ?? ''));
+ $tb = trim((string) ($b['timestamp_iso'] ?? ''));
+ if ($ta === $tb) {
+ return strcmp((string) ($b['entry_id'] ?? ''), (string) ($a['entry_id'] ?? ''));
+ }
+ if ($ta === '') {
+ return 1;
+ }
+ if ($tb === '') {
+ return -1;
+ }
+
+ return strcmp($tb, $ta);
+ });
+
+ $deduped = [];
+ $seen = [];
+ foreach ($combined as $entry) {
+ $signature = $this->entrySignature($entry);
+ if (isset($seen[$signature])) {
+ continue;
+ }
+ $seen[$signature] = true;
+ $deduped[] = $entry;
+ if (count($deduped) >= $maxEntries) {
+ break;
+ }
+ }
+
+ return $deduped;
+ }
+
+ /**
+ * @param array $entry
+ */
+ private function entrySignature(array $entry): string
+ {
+ return hash(
+ 'sha256',
+ implode('|', [
+ (string) ($entry['ticket_no'] ?? ''),
+ (string) ($entry['timestamp_iso'] ?? ''),
+ (string) ($entry['actor'] ?? ''),
+ (string) ($entry['type'] ?? ''),
+ hash('sha256', (string) ($entry['message'] ?? '')),
+ ])
+ );
+ }
+
+ /**
+ * @param array> $entries
+ */
+ private function hasFallbackOnlyEntries(array $entries): bool
+ {
+ if ($entries === []) {
+ return false;
+ }
+
+ foreach ($entries as $entry) {
+ if (($entry['source'] ?? '') === 'soap') {
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+ private function buildODataMessage(string $type, string $stateSubtype, string $recordId): string
+ {
+ $type = trim($type);
+ $recordId = trim($recordId);
+ $stateSubtype = trim($stateSubtype);
+
+ if ($type === 'Status' && $stateSubtype !== '') {
+ return $stateSubtype;
+ }
+
+ if ($recordId !== '') {
+ return $recordId;
+ }
+
+ if ($type !== '') {
+ return $type;
+ }
+
+ return '';
+ }
+
+ private function normalizeEntryNo(mixed $value): string
+ {
+ if (is_int($value)) {
+ return (string) $value;
+ }
+
+ $raw = trim((string) $value);
+ if ($raw === '' || preg_match('/^\d+$/', $raw) !== 1) {
+ return '';
+ }
+
+ return (string) ((int) $raw);
+ }
+
+ private function normalizeSoapMessageText(string $message): string
+ {
+ $message = trim($message);
+ if ($message === '') {
+ return '';
+ }
+
+ $message = preg_replace('#
#i', "\n", $message) ?? $message;
+ $message = preg_replace('#
\s*#i', "\n\n", $message) ?? $message;
+ $message = strip_tags($message);
+ $message = html_entity_decode($message, ENT_QUOTES | ENT_HTML5, 'UTF-8');
+ $message = str_replace(["\r\n", "\r"], "\n", $message);
+ $message = preg_replace("/\n{3,}/", "\n\n", $message) ?? $message;
+
+ return trim($message);
+ }
+
+ /**
+ * @return array{0: string, 1: string}
+ */
+ private function normalizeType(string $type, string $message): array
+ {
+ $type = trim($type);
+ $needle = mb_strtolower($type . ' ' . $message);
+ if ($type === 'Status' || str_contains($needle, 'status')) {
+ return ['Status', 'status'];
+ }
+ if ($type === 'Weiterleitung' || str_contains($needle, 'weiterleit') || str_contains($needle, 'forward')) {
+ return ['Weiterleitung', 'forward'];
+ }
+ if ($type === 'Datei' || str_contains($needle, 'datei') || str_contains($needle, 'file') || str_contains($needle, 'anhang')) {
+ return ['Datei', 'file'];
+ }
+ if ($type === 'Nachricht' || $type === 'Message') {
+ return ['Nachricht', 'message'];
+ }
+ if ($type !== '') {
+ return [$type, 'other'];
+ }
+
+ return ['Nachricht', 'message'];
+ }
+
+ private function ticketSortStamp(array $ticket): int
+ {
+ $activity = trim((string) ($ticket['Last_Activity_Date'] ?? ''));
+ if ($activity !== '' && $activity !== '0001-01-01T00:00:00Z') {
+ $ts = strtotime($activity);
+ if (is_int($ts)) {
+ return $ts;
+ }
+ }
+
+ $created = trim((string) ($ticket['Created_On'] ?? ''));
+ if ($created !== '' && $created !== '0001-01-01T00:00:00Z') {
+ $ts = strtotime($created);
+ if (is_int($ts)) {
+ return $ts;
+ }
+ }
+
+ return 0;
+ }
+
+ private function combineDateTimeToIso(string $date, string $time): string
+ {
+ $date = trim($date);
+ $time = $this->normalizeTime($time);
+ if ($date === '') {
+ return '';
+ }
+
+ if ($time === '') {
+ if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $date) === 1) {
+ return $date . 'T00:00:00';
+ }
+
+ return '';
+ }
+
+ $ts = strtotime($date . ' ' . $time);
+ if (!is_int($ts)) {
+ return '';
+ }
+
+ return date('Y-m-d\TH:i:s', $ts);
+ }
+
+ private function convertEuropeanDateTimeToIso(string $date, string $time): string
+ {
+ if (preg_match('/^(\d{2})\.(\d{2})\.(\d{4})$/', trim($date), $m) !== 1) {
+ return '';
+ }
+
+ return $this->combineDateTimeToIso($m[3] . '-' . $m[2] . '-' . $m[1], $time);
+ }
+
+ /**
+ * @return array{0: string, 1: string}
+ */
+ private function splitTimestamp(string $timestamp): array
+ {
+ $timestamp = trim($timestamp);
+ if ($timestamp === '') {
+ return ['', ''];
+ }
+
+ $ts = strtotime($timestamp);
+ if (!is_int($ts)) {
+ return ['', ''];
+ }
+
+ return [date('Y-m-d', $ts), date('H:i:s', $ts)];
+ }
+
+ private function normalizeTime(string $time): string
+ {
+ $time = trim($time);
+ if ($time === '') {
+ return '';
+ }
+
+ if (preg_match('/^(\d{2}:\d{2})(:\d{2})?/', $time, $m) === 1) {
+ return isset($m[2]) && $m[2] !== '' ? ($m[1] . $m[2]) : ($m[1] . ':00');
+ }
+
+ return $time;
+ }
+
+ /**
+ * @param array> $odataEntries
+ * @param array> $soapEntries
+ * @return array
+ */
+ private function resolveContactActorNames(string $ticketNo, array $odataEntries, array $soapEntries): array
+ {
+ $contactActorIds = $this->extractContactActorIds([...$odataEntries, ...$soapEntries]);
+ if ($contactActorIds === []) {
+ return [];
+ }
+
+ $ticket = null;
+ try {
+ $ticket = $this->bcODataGateway->getTicket($ticketNo);
+ } catch (\Throwable) {
+ $ticket = null;
+ }
+
+ $currentContactName = trim((string) (($ticket['Current_Contact_Name'] ?? '')));
+ $customerName = trim((string) (($ticket['Company_Contact_Name'] ?? '')));
+
+ $nameMap = [];
+ if ($customerName !== '') {
+ try {
+ $contacts = $this->bcODataGateway->getContactsForCustomer('', $customerName);
+ foreach ($contacts as $contact) {
+ $contactNo = strtoupper(trim((string) ($contact['No'] ?? '')));
+ $contactName = trim((string) ($contact['Name'] ?? ''));
+ if ($contactNo !== '' && $contactName !== '') {
+ $nameMap[$contactNo] = $contactName;
+ }
+ }
+ } catch (\Throwable) {
+ $nameMap = [];
+ }
+ }
+
+ $resolved = [];
+ foreach ($contactActorIds as $contactActorId) {
+ if (isset($nameMap[$contactActorId])) {
+ $resolved[$contactActorId] = $nameMap[$contactActorId];
+ }
+ }
+
+ if ($currentContactName !== '') {
+ $remaining = array_values(array_filter(
+ $contactActorIds,
+ static fn (string $contactActorId): bool => !isset($resolved[$contactActorId])
+ ));
+ if (count($remaining) === 1) {
+ $resolved[$remaining[0]] = $currentContactName;
+ }
+ }
+
+ return $resolved;
+ }
+
+ /**
+ * @param array> $entries
+ * @return array>
+ */
+ private function applyActorNameMap(array $entries, array $actorNameMap): array
+ {
+ if ($entries === [] || $actorNameMap === []) {
+ return $entries;
+ }
+
+ foreach ($entries as $index => $entry) {
+ $actor = strtoupper(trim((string) ($entry['actor'] ?? '')));
+ if ($actor !== '' && isset($actorNameMap[$actor])) {
+ $entries[$index]['actor'] = $actorNameMap[$actor];
+ }
+ }
+
+ return $entries;
+ }
+
+ /**
+ * @param array> $entries
+ * @return array
+ */
+ private function extractContactActorIds(array $entries): array
+ {
+ $ids = [];
+ foreach ($entries as $entry) {
+ $actor = strtoupper(trim((string) ($entry['actor'] ?? '')));
+ if ($this->isContactActorId($actor)) {
+ $ids[$actor] = true;
+ }
+ }
+
+ return array_keys($ids);
+ }
+
+ private function isContactActorId(string $value): bool
+ {
+ return $value !== '' && preg_match('/^KT\d+$/', $value) === 1;
+ }
+
+ private function normalizeActorRole(string $createdByType, string $actor): string
+ {
+ $type = mb_strtolower(trim($createdByType));
+ if ($type !== '') {
+ if (
+ str_contains($type, 'kontakt')
+ || str_contains($type, 'contact')
+ || str_contains($type, 'customer')
+ || str_contains($type, 'debitor')
+ || str_contains($type, 'client')
+ || str_contains($type, 'external')
+ ) {
+ return 'customer';
+ }
+ if (
+ str_contains($type, 'user')
+ || str_contains($type, 'benutzer')
+ || str_contains($type, 'support')
+ || str_contains($type, 'agent')
+ || str_contains($type, 'internal')
+ ) {
+ return 'support';
+ }
+ }
+
+ $actorCode = strtoupper(trim($actor));
+ if ($this->isContactActorId($actorCode)) {
+ return 'customer';
+ }
+
+ return 'support';
+ }
+}
diff --git a/modules/helpdesk/module.php b/modules/helpdesk/module.php
index 516ca77..a4c9d04 100644
--- a/modules/helpdesk/module.php
+++ b/modules/helpdesk/module.php
@@ -26,6 +26,8 @@ return [
['path' => 'helpdesk/debitor-tickets-data', 'target' => 'helpdesk/debitor-tickets-data'],
['path' => 'helpdesk/debitor-ticket-categories-data', 'target' => 'helpdesk/debitor-ticket-categories-data'],
['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'],
],
'public_paths' => [],
diff --git a/modules/helpdesk/pages/helpdesk/debitor(default).phtml b/modules/helpdesk/pages/helpdesk/debitor(default).phtml
index d35c109..67d6fa2 100644
--- a/modules/helpdesk/pages/helpdesk/debitor(default).phtml
+++ b/modules/helpdesk/pages/helpdesk/debitor(default).phtml
@@ -38,6 +38,7 @@ $locationDisplay = $postCode !== '' && $city !== '' ? $postCode . ' ' . $city :
data-tickets-url=""
data-ticket-base-url=""
data-summary-url=""
+ data-communication-url=""
>
+
+
+
+
@@ -272,6 +289,23 @@ $locationDisplay = $postCode !== '' && $city !== '' ? $postCode . ' ' . $city :
'No recent activity found.' => t('No recent activity found.'),
'Could not load contacts.' => t('Could not load contacts.'),
'Could not load tickets.' => t('Could not load tickets.'),
+ 'Ticket communication' => t('Ticket communication'),
+ 'Last 5 tickets' => t('Last 5 tickets'),
+ 'No communication found.' => t('No communication found.'),
+ 'Could not load communication history.' => t('Could not load communication history.'),
+ 'Some ticket communications could not be loaded completely.' => t('Some ticket communications could not be loaded completely.'),
+ 'Unknown user' => t('Unknown user'),
+ 'Unknown time' => t('Unknown time'),
+ 'Ticket' => t('Ticket'),
+ 'Activity' => t('Activity'),
+ 'Message' => t('Message'),
+ 'Status change' => t('Status change'),
+ 'Forwarded' => t('Forwarded'),
+ 'attached a file' => t('attached a file'),
+ 'logged an activity' => t('logged an activity'),
+ 'on' => t('on'),
+ 'SOAP' => t('SOAP'),
+ 'Activity fallback' => t('Activity fallback'),
'Loading...' => t('Loading...'),
]); ?>
diff --git a/modules/helpdesk/pages/helpdesk/debitor-communication-data().php b/modules/helpdesk/pages/helpdesk/debitor-communication-data().php
new file mode 100644
index 0000000..06709ac
--- /dev/null
+++ b/modules/helpdesk/pages/helpdesk/debitor-communication-data().php
@@ -0,0 +1,53 @@
+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', ''));
+
+if ($customerNo === '' || $customerName === '') {
+ http_response_code(400);
+ Router::json(['ok' => false, 'error' => 'missing_parameters']);
+
+ return;
+}
+
+$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);
+
+if (is_array($cached) && isset($cached['fetched_at']) && (time() - (int) $cached['fetched_at']) < $cacheTtl) {
+ Router::json((array) ($cached['payload'] ?? ['ok' => false]));
+
+ return;
+}
+
+$service = app(TicketCommunicationService::class);
+$result = $service->loadDebitorCommunicationFeed($customerNo, $customerName, 5, 40, 250);
+if (($result['ok'] ?? false) === true) {
+ $sessionStore->set($cacheKey, [
+ 'payload' => $result,
+ 'fetched_at' => time(),
+ ]);
+}
+
+Router::json($result);
diff --git a/modules/helpdesk/pages/helpdesk/debitor-summary-data().php b/modules/helpdesk/pages/helpdesk/debitor-summary-data().php
index c2f964b..4f198b7 100644
--- a/modules/helpdesk/pages/helpdesk/debitor-summary-data().php
+++ b/modules/helpdesk/pages/helpdesk/debitor-summary-data().php
@@ -3,6 +3,7 @@
use MintyPHP\Module\Helpdesk\HelpdeskAuthorizationPolicy;
use MintyPHP\Module\Helpdesk\Service\DebitorDetailService;
use MintyPHP\Http\SessionStoreInterface;
+use MintyPHP\Router;
use MintyPHP\Support\Guard;
Guard::requireLogin();
@@ -15,8 +16,8 @@ $customerNo = trim((string) $request->query('customerNo', ''));
$customerName = trim((string) $request->query('customerName', ''));
if ($customerNo === '' || $customerName === '') {
- header('Content-Type: application/json');
- echo json_encode(['ok' => false, 'error' => 'Missing parameters']);
+ http_response_code(400);
+ Router::json(['ok' => false, 'error' => 'Missing parameters']);
return;
}
@@ -31,9 +32,12 @@ $cacheTtl = 120;
$cached = $sessionStore->get($cacheKey);
if (is_array($cached) && isset($cached['fetched_at']) && (time() - (int) $cached['fetched_at']) < $cacheTtl) {
- $summary = DebitorDetailService::summarizeTickets($cached['tickets'] ?? []);
- header('Content-Type: application/json');
- echo json_encode($summary);
+ $cachedTickets = $cached['tickets'] ?? [];
+ if (!is_array($cachedTickets)) {
+ $cachedTickets = [];
+ }
+ $summary = DebitorDetailService::summarizeTickets($cachedTickets);
+ Router::json($summary);
return;
}
@@ -43,13 +47,15 @@ $service = app(DebitorDetailService::class);
$ticketsResult = $service->loadTickets($customerNo, $customerName);
if (!($ticketsResult['ok'] ?? false)) {
- header('Content-Type: application/json');
- echo json_encode(['ok' => false, 'error' => $ticketsResult['error'] ?? 'Failed to load tickets']);
+ 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,
@@ -58,5 +64,4 @@ $sessionStore->set($cacheKey, [
$summary = DebitorDetailService::summarizeTickets($tickets);
-header('Content-Type: application/json');
-echo json_encode($summary);
+Router::json($summary);
diff --git a/modules/helpdesk/pages/helpdesk/ticket($id).php b/modules/helpdesk/pages/helpdesk/ticket($id).php
index ce13245..f1a036f 100644
--- a/modules/helpdesk/pages/helpdesk/ticket($id).php
+++ b/modules/helpdesk/pages/helpdesk/ticket($id).php
@@ -41,6 +41,7 @@ if ($ticket === null && !$connectionError) {
$ticketCustomerNo = $fromCustomerNo;
$ticketDescription = (string) ($ticket['Description'] ?? $ticketNo);
$ticketLogUrl = lurl('helpdesk/ticket-log-data');
+$ticketCommunicationUrl = lurl('helpdesk/ticket-communication-data');
Buffer::set('title', $ticketDescription . ' — ' . t('Helpdesk'));
Buffer::set('style_groups', json_encode(['helpdesk']));
diff --git a/modules/helpdesk/pages/helpdesk/ticket(default).phtml b/modules/helpdesk/pages/helpdesk/ticket(default).phtml
index 8956773..6248834 100644
--- a/modules/helpdesk/pages/helpdesk/ticket(default).phtml
+++ b/modules/helpdesk/pages/helpdesk/ticket(default).phtml
@@ -8,6 +8,7 @@
* @var string $ticketCustomerNo
* @var string $ticketDescription
* @var string $ticketLogUrl
+ * @var string $ticketCommunicationUrl
*/
$ticketNo = $ticketNo ?? '';
@@ -17,6 +18,7 @@ $connectionError = $connectionError ?? false;
$ticketCustomerNo = $ticketCustomerNo ?? '';
$ticketDescription = $ticketDescription ?? '';
$ticketLogUrl = $ticketLogUrl ?? '';
+$ticketCommunicationUrl = $ticketCommunicationUrl ?? '';
$backUrl = $ticketCustomerNo !== '' ? lurl('helpdesk/debitor/' . rawurlencode($ticketCustomerNo)) : lurl('helpdesk/debitor');
@@ -24,6 +26,7 @@ $backUrl = $ticketCustomerNo !== '' ? lurl('helpdesk/debitor/' . rawurlencode($t
-
-
+
+
@@ -147,10 +156,22 @@ $backUrl = $ticketCustomerNo !== '' ? lurl('helpdesk/debitor/' . rawurlencode($t
'Activity' => t('Activity'),
'No activity found.' => t('No activity found.'),
'Could not load activity log.' => t('Could not load activity log.'),
+ 'Communication history' => t('Communication history'),
+ 'No communication found.' => t('No communication found.'),
+ 'Could not load communication history.' => t('Could not load communication history.'),
+ 'Live communication unavailable, showing activity fallback.' => t('Live communication unavailable, showing activity fallback.'),
+ 'Ticket' => t('Ticket'),
+ 'Unknown user' => t('Unknown user'),
+ 'Unknown time' => t('Unknown time'),
+ 'Source' => t('Source'),
+ 'SOAP' => t('SOAP'),
+ 'Activity fallback' => t('Activity fallback'),
'sent a message' => t('sent a message'),
'changed status' => t('changed status'),
'Forwarded' => t('Forwarded'),
'attached a file' => t('attached a file'),
+ 'logged an activity' => t('logged an activity'),
+ 'on' => t('on'),
'Process' => t('Process'),
'Loading...' => t('Loading...'),
]); ?>
diff --git a/modules/helpdesk/pages/helpdesk/ticket-communication-data().php b/modules/helpdesk/pages/helpdesk/ticket-communication-data().php
new file mode 100644
index 0000000..b9b372a
--- /dev/null
+++ b/modules/helpdesk/pages/helpdesk/ticket-communication-data().php
@@ -0,0 +1,51 @@
+method() !== 'GET') {
+ http_response_code(405);
+ Router::json(['ok' => false, 'error' => 'method_not_allowed']);
+
+ return;
+}
+
+$ticketNo = trim((string) $request->query('ticketNo', ''));
+if ($ticketNo === '') {
+ http_response_code(400);
+ Router::json(['ok' => false, 'error' => 'missing_parameters']);
+
+ return;
+}
+
+$sessionStore = app(SessionStoreInterface::class);
+$session = $sessionStore->all();
+$tenantId = (int) (($session['current_tenant']['id'] ?? 0));
+$tenantScope = $tenantId > 0 ? (string) $tenantId : 'global';
+$cacheKey = 'module.helpdesk.ticket_comm_cache.v2.' . $tenantScope . '.' . $ticketNo;
+$cacheTtl = 120;
+$cached = $sessionStore->get($cacheKey);
+
+if (is_array($cached) && isset($cached['fetched_at']) && (time() - (int) $cached['fetched_at']) < $cacheTtl) {
+ Router::json((array) ($cached['payload'] ?? ['ok' => false]));
+
+ return;
+}
+
+$service = app(TicketCommunicationService::class);
+$result = $service->loadTicketCommunicationFeed($ticketNo, 40);
+if (($result['ok'] ?? false) === true) {
+ $sessionStore->set($cacheKey, [
+ 'payload' => $result,
+ 'fetched_at' => time(),
+ ]);
+}
+
+Router::json($result);
diff --git a/modules/helpdesk/tests/Module/Helpdesk/Service/BcSoapGatewayTest.php b/modules/helpdesk/tests/Module/Helpdesk/Service/BcSoapGatewayTest.php
new file mode 100644
index 0000000..c464981
--- /dev/null
+++ b/modules/helpdesk/tests/Module/Helpdesk/Service/BcSoapGatewayTest.php
@@ -0,0 +1,142 @@
+createMock(HelpdeskSettingsGateway::class);
+ $settings->method('isConfigured')->willReturn(true);
+ $settings->method('getODataBaseUrl')->willReturn('https://bc.example.com:7048/BusinessCentral-NUP/ODataV4');
+ $settings->method('getCompanyName')->willReturn('Test Company');
+ $settings->method('getAuthMode')->willReturn(HelpdeskSettingsGateway::AUTH_MODE_BASIC);
+ $settings->method('getBasicUser')->willReturn('tester');
+ $settings->method('getBasicPassword')->willReturn('secret');
+
+ $oauthTokenService = $this->createMock(HelpdeskOAuthTokenService::class);
+ $sessionStore = $this->createMock(SessionStoreInterface::class);
+ $sessionStore->method('all')->willReturn([
+ 'current_tenant' => ['id' => 1],
+ ]);
+
+ return new class($settings, $oauthTokenService, $sessionStore, $response) extends BcSoapGateway {
+ /**
+ * @param array{http_code:int,body:string,error:string} $fakeResponse
+ */
+ public function __construct(
+ HelpdeskSettingsGateway $settings,
+ HelpdeskOAuthTokenService $oauthTokenService,
+ SessionStoreInterface $sessionStore,
+ private readonly array $fakeResponse
+ ) {
+ parent::__construct($settings, $oauthTokenService, $sessionStore);
+ }
+
+ protected function sendSoapRequest(
+ string $endpoint,
+ string $soapAction,
+ string $requestBody,
+ array $headers,
+ string $basicUser,
+ string $basicPassword
+ ): array {
+ return $this->fakeResponse;
+ }
+ };
+ }
+
+ public function testGetTicketCommunicationTextReturnsErrorForEmptyTicketNo(): void
+ {
+ $gateway = $this->createGateway([
+ 'http_code' => 200,
+ 'body' => '',
+ 'error' => '',
+ ]);
+
+ $result = $gateway->getTicketCommunicationText('');
+
+ $this->assertFalse($result['ok']);
+ $this->assertSame('Missing ticket number', $result['error']);
+ }
+
+ public function testGetTicketCommunicationTextParsesSuccessfulResponse(): void
+ {
+ $soapBody = <<<'XML'
+
+
+
+
+ 0
+ Hallo aus SOAP
+ line 1
+
+
+
+XML;
+
+ $gateway = $this->createGateway([
+ 'http_code' => 200,
+ 'body' => $soapBody,
+ 'error' => '',
+ ]);
+
+ $result = $gateway->getTicketCommunicationText('T26-1216');
+
+ $this->assertTrue($result['ok']);
+ $this->assertTrue($result['soap_used']);
+ $this->assertSame(0, $result['return_value']);
+ $this->assertSame('Hallo aus SOAP', $result['communication_text']);
+ $this->assertSame('line 1', $result['message_entries']);
+ }
+
+ public function testGetTicketCommunicationTextReturnsErrorWhenReturnCodeIndicatesFailure(): void
+ {
+ $soapBody = <<<'XML'
+
+
+
+
+ 400
+
+
+
+
+XML;
+
+ $gateway = $this->createGateway([
+ 'http_code' => 200,
+ 'body' => $soapBody,
+ 'error' => '',
+ ]);
+
+ $result = $gateway->getTicketCommunicationText('T26-1216');
+
+ $this->assertFalse($result['ok']);
+ $this->assertSame(400, $result['return_value']);
+ $this->assertSame('BC SOAP returned error code 400', $result['error']);
+ }
+
+ public function testGetTicketCommunicationTextReturnsErrorForInvalidSoapXml(): void
+ {
+ $gateway = $this->createGateway([
+ 'http_code' => 200,
+ 'body' => 'invalid-xml',
+ 'error' => '',
+ ]);
+
+ $result = $gateway->getTicketCommunicationText('T26-1216');
+
+ $this->assertFalse($result['ok']);
+ $this->assertSame('Invalid SOAP XML response', $result['error']);
+ }
+}
diff --git a/modules/helpdesk/tests/Module/Helpdesk/Service/TicketCommunicationServiceTest.php b/modules/helpdesk/tests/Module/Helpdesk/Service/TicketCommunicationServiceTest.php
new file mode 100644
index 0000000..1f92572
--- /dev/null
+++ b/modules/helpdesk/tests/Module/Helpdesk/Service/TicketCommunicationServiceTest.php
@@ -0,0 +1,234 @@
+createMock(BcSoapGateway::class);
+ $soapGateway->method('getTicketCommunicationText')->willReturn([
+ 'ok' => true,
+ 'soap_used' => true,
+ 'return_value' => 0,
+ 'communication_text' => "2026-04-03 08:10 - Alice: Hallo\n2026-04-03 08:11 - Alice: Update",
+ 'message_entries' => '',
+ ]);
+
+ $odataGateway = $this->createMock(BcODataGateway::class);
+ $odataGateway->method('getTicketLog')->with('T26-1216')->willReturn([
+ [
+ 'Entry_No' => 1,
+ 'Created_By' => 'ICTEST',
+ 'Creation_Date' => '2026-04-03',
+ 'Creation_Time' => '07:59:00.000',
+ 'Type' => 'Status',
+ 'State_Subtype' => 'Offen',
+ 'Record_ID' => 'ICI Support Ticket: T26-1216',
+ ],
+ ]);
+
+ $service = new TicketCommunicationService($soapGateway, $odataGateway);
+ $result = $service->loadTicketCommunicationFeed('T26-1216', 40);
+
+ $this->assertTrue($result['ok']);
+ $this->assertIsArray($result['entries']);
+ $this->assertGreaterThanOrEqual(3, count($result['entries']));
+ $this->assertSame(true, $result['meta']['soap_used']);
+ $this->assertSame(false, $result['meta']['fallback_used']);
+
+ $sources = array_unique(array_map(static fn (array $entry): string => (string) ($entry['source'] ?? ''), $result['entries']));
+ $this->assertContains('soap', $sources);
+ $this->assertContains('odata', $sources);
+ }
+
+ public function testLoadTicketCommunicationFeedFallsBackToODataWhenSoapFails(): void
+ {
+ $soapGateway = $this->createMock(BcSoapGateway::class);
+ $soapGateway->method('getTicketCommunicationText')->willReturn([
+ 'ok' => false,
+ 'soap_used' => false,
+ 'return_value' => 400,
+ 'communication_text' => '',
+ 'message_entries' => '',
+ 'error' => 'BC SOAP returned error code 400',
+ ]);
+
+ $odataGateway = $this->createMock(BcODataGateway::class);
+ $odataGateway->method('getTicketLog')->with('T26-1216')->willReturn([
+ [
+ 'Entry_No' => 1,
+ 'Created_By' => 'ICTEST',
+ 'Creation_Date' => '2026-04-03',
+ 'Creation_Time' => '07:59:00.000',
+ 'Type' => 'Nachricht',
+ 'State_Subtype' => '',
+ 'Record_ID' => 'Fallback entry',
+ ],
+ ]);
+
+ $service = new TicketCommunicationService($soapGateway, $odataGateway);
+ $result = $service->loadTicketCommunicationFeed('T26-1216', 40);
+
+ $this->assertTrue($result['ok']);
+ $this->assertNotEmpty($result['entries']);
+ $this->assertSame(false, $result['meta']['soap_used']);
+ $this->assertSame(true, $result['meta']['fallback_used']);
+ $this->assertSame('BC SOAP returned error code 400', $result['meta']['soap_error']);
+ $this->assertSame('odata', $result['entries'][0]['source']);
+ }
+
+ public function testLoadDebitorCommunicationFeedUsesLastTenTickets(): 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',
+ ]);
+
+ $tickets = [];
+ for ($i = 1; $i <= 12; $i++) {
+ $tickets[] = [
+ 'No' => 'T' . $i,
+ 'Last_Activity_Date' => sprintf('2026-04-%02dT08:00:00Z', $i),
+ 'Created_On' => sprintf('2026-03-%02dT08:00:00Z', $i),
+ ];
+ }
+
+ $odataGateway = $this->createMock(BcODataGateway::class);
+ $odataGateway->method('getTicketsForCustomer')->with('10001', 'Test GmbH')->willReturn($tickets);
+ $odataGateway->method('getTicketLog')->willReturnCallback(static function (string $ticketNo): array {
+ return [[
+ 'Entry_No' => 1,
+ 'Created_By' => 'ICTEST',
+ 'Creation_Date' => '2026-04-03',
+ 'Creation_Time' => '07:59:00.000',
+ 'Type' => 'Nachricht',
+ 'State_Subtype' => '',
+ 'Record_ID' => 'Entry ' . $ticketNo,
+ ]];
+ });
+
+ $service = new TicketCommunicationService($soapGateway, $odataGateway);
+ $result = $service->loadDebitorCommunicationFeed('10001', 'Test GmbH', 10, 40, 250);
+
+ $this->assertTrue($result['ok']);
+ $this->assertSame(10, $result['meta']['tickets_considered']);
+ $this->assertSame(10, $result['meta']['tickets_with_data']);
+ $this->assertSame(10, $result['meta']['soap_partial_failures']);
+
+ $ticketNos = array_unique(array_map(static fn (array $entry): string => (string) ($entry['ticket_no'] ?? ''), $result['entries']));
+ $this->assertCount(10, $ticketNos);
+ $this->assertNotContains('T1', $ticketNos);
+ $this->assertNotContains('T2', $ticketNos);
+ $this->assertContains('T12', $ticketNos);
+ }
+
+ public function testLoadTicketCommunicationFeedRespectsEntryLimit(): void
+ {
+ $soapGateway = $this->createMock(BcSoapGateway::class);
+ $soapGateway->method('getTicketCommunicationText')->willReturn([
+ 'ok' => true,
+ 'soap_used' => true,
+ 'return_value' => 0,
+ 'communication_text' => "2026-04-03 08:10 - Alice: A\n2026-04-03 08:11 - Alice: B\n2026-04-03 08:12 - Alice: C",
+ 'message_entries' => '',
+ ]);
+
+ $odataGateway = $this->createMock(BcODataGateway::class);
+ $odataGateway->method('getTicketLog')->with('T26-1216')->willReturn([]);
+
+ $service = new TicketCommunicationService($soapGateway, $odataGateway);
+ $result = $service->loadTicketCommunicationFeed('T26-1216', 2);
+
+ $this->assertTrue($result['ok']);
+ $this->assertCount(2, $result['entries']);
+ }
+
+ public function testLoadTicketCommunicationFeedMapsSoapDataBlobByEntryNo(): void
+ {
+ $soapGateway = $this->createMock(BcSoapGateway::class);
+ $soapGateway->method('getTicketCommunicationText')->willReturn([
+ 'ok' => true,
+ 'soap_used' => true,
+ 'return_value' => 100,
+ 'communication_text' => '[{"EntryNo":"19356","DataBlob":"
Hallo Team
"}]',
+ 'message_entries' => '',
+ ]);
+
+ $odataGateway = $this->createMock(BcODataGateway::class);
+ $odataGateway->method('getTicketLog')->willReturn([
+ [
+ 'Entry_No' => 19356,
+ 'Created_By' => 'KT002452',
+ 'Creation_Date' => '2026-04-02',
+ 'Creation_Time' => '13:54:00.000',
+ 'Type' => 'Nachricht',
+ 'State_Subtype' => '',
+ 'Record_ID' => '',
+ ],
+ ]);
+
+ $service = new TicketCommunicationService($soapGateway, $odataGateway);
+ $result = $service->loadTicketCommunicationFeed('T26-1204', 40);
+
+ $this->assertTrue($result['ok']);
+ $this->assertNotEmpty($result['entries']);
+ $this->assertSame(false, $result['meta']['fallback_used']);
+ $this->assertSame('soap', $result['entries'][0]['source']);
+ $this->assertSame('Hallo Team', $result['entries'][0]['message']);
+ }
+
+ public function testLoadTicketCommunicationFeedResolvesContactCodeToName(): void
+ {
+ $soapGateway = $this->createMock(BcSoapGateway::class);
+ $soapGateway->method('getTicketCommunicationText')->willReturn([
+ 'ok' => false,
+ 'soap_used' => false,
+ 'return_value' => 400,
+ 'communication_text' => '',
+ 'message_entries' => '',
+ 'error' => 'BC SOAP returned error code 400',
+ ]);
+
+ $odataGateway = $this->createMock(BcODataGateway::class);
+ $odataGateway->method('getTicketLog')->with('T26-1204')->willReturn([
+ [
+ 'Entry_No' => 19356,
+ 'Created_By' => 'KT002967',
+ 'Creation_Date' => '2026-04-02',
+ 'Creation_Time' => '13:54:00.000',
+ 'Type' => 'Nachricht',
+ 'State_Subtype' => '',
+ 'Record_ID' => 'Hallo',
+ ],
+ ]);
+ $odataGateway->method('getTicket')->with('T26-1204')->willReturn([
+ 'No' => 'T26-1204',
+ 'Company_Contact_Name' => 'Aero-Dienst GmbH',
+ 'Current_Contact_Name' => 'Fallback Kontakt',
+ ]);
+ $odataGateway->method('getContactsForCustomer')->with('', 'Aero-Dienst GmbH')->willReturn([
+ [
+ 'No' => 'KT002967',
+ 'Name' => 'Felix Schneider',
+ ],
+ ]);
+
+ $service = new TicketCommunicationService($soapGateway, $odataGateway);
+ $result = $service->loadTicketCommunicationFeed('T26-1204', 40);
+
+ $this->assertTrue($result['ok']);
+ $this->assertNotEmpty($result['entries']);
+ $this->assertSame('Felix Schneider', $result['entries'][0]['actor']);
+ }
+}
diff --git a/modules/helpdesk/web/css/helpdesk.css b/modules/helpdesk/web/css/helpdesk.css
index 947c8a7..efc3a0f 100644
--- a/modules/helpdesk/web/css/helpdesk.css
+++ b/modules/helpdesk/web/css/helpdesk.css
@@ -111,4 +111,321 @@
color: var(--app-muted, #6c757d);
font-size: 0.8125rem;
}
+
+ /* Communication feed (ticket + debtor aside) */
+ .helpdesk-comm-hint {
+ margin: 0.75rem 1rem 0;
+ font-size: 0.8125rem;
+ color: var(--app-muted, #6c757d);
+ }
+
+ .helpdesk-comm-feed {
+ display: flex;
+ flex-direction: column;
+ gap: 0.75rem;
+ margin: 0;
+ padding: 0.75rem 1rem;
+ list-style: none;
+ max-height: 26rem;
+ min-width: 0;
+ }
+
+ .helpdesk-comm-feed > li {
+ margin: 0;
+ padding: 0;
+ list-style: none;
+ }
+
+ #ticket-communication .helpdesk-comm-feed {
+ max-height: 34rem;
+ }
+
+ #app-details-aside-section .helpdesk-comm-feed {
+ max-height: none;
+ height: 100%;
+ min-height: 0;
+ padding: 0.5rem 0 0;
+ }
+
+ .helpdesk-chat-row {
+ display: flex;
+ flex-direction: column;
+ gap: 0.25rem;
+ max-width: 100%;
+ min-width: 0;
+ }
+
+ .helpdesk-chat-row--message {
+ --helpdesk-tl-x: 7px;
+ --helpdesk-tl-dot: 10px;
+ position: relative;
+ align-self: stretch;
+ padding: 0.05rem 0 0.05rem 1.35rem;
+ }
+
+ .helpdesk-chat-row--customer {
+ align-self: flex-start;
+ }
+
+ .helpdesk-chat-row--support {
+ align-self: flex-start;
+ }
+
+ .helpdesk-chat-row--activity {
+ align-self: stretch;
+ max-width: 100%;
+ gap: 0.15rem;
+ }
+
+ .helpdesk-chat-meta-block {
+ display: grid;
+ gap: 0.18rem;
+ min-width: 0;
+ }
+
+ .helpdesk-chat-meta-block--center {
+ justify-items: center;
+ }
+
+ .helpdesk-chat-meta-row {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 0.12rem 0.45rem;
+ font-size: 0.6875rem;
+ color: var(--app-muted, #6c757d);
+ min-width: 0;
+ }
+
+ .helpdesk-chat-meta-row > * {
+ min-width: 0;
+ }
+
+ .helpdesk-chat-meta-row > * + *::before {
+ content: "•";
+ margin-right: 0.4rem;
+ color: color-mix(in srgb, var(--app-muted, #6c757d) 70%, transparent);
+ }
+
+ .helpdesk-chat-meta-row--center {
+ justify-content: center;
+ }
+
+ .helpdesk-chat-meta-item {
+ line-height: 1.35;
+ max-width: 100%;
+ overflow-wrap: anywhere;
+ }
+
+ .helpdesk-chat-meta-link {
+ color: color-mix(in srgb, var(--app-primary, #1a56db) 88%, #0b2e7a);
+ text-decoration: none;
+ line-height: 1.35;
+ max-width: 100%;
+ overflow-wrap: anywhere;
+ }
+
+ .helpdesk-chat-meta-link:hover {
+ text-decoration: underline;
+ }
+
+ .helpdesk-chat-actor {
+ display: block;
+ font-size: 0.8125rem;
+ font-weight: 600;
+ color: var(--app-contrast, #1f2937);
+ overflow-wrap: anywhere;
+ }
+
+ .helpdesk-chat-time {
+ font-size: 0.75rem;
+ color: var(--app-muted, #6c757d);
+ white-space: normal;
+ overflow-wrap: anywhere;
+ }
+
+ .helpdesk-chat-meta-row--center .helpdesk-chat-time {
+ margin-left: 0;
+ }
+
+ .helpdesk-chat-bubble {
+ margin: 0;
+ border: 1px solid #d0d7de;
+ background: #fff;
+ border-radius: 0.5rem;
+ padding: 0.62rem 0.78rem;
+ white-space: pre-wrap;
+ word-break: break-word;
+ overflow-wrap: anywhere;
+ line-height: 1.45;
+ box-shadow: 0 1px 0 rgb(27 31 36 / 0.04);
+ }
+
+ .helpdesk-chat-row--customer .helpdesk-chat-bubble {
+ background: var(--app-secondary);
+ color: var(--app-secondary-inverse);
+ border-color: var(--app-secondary-border);
+ }
+
+ .helpdesk-chat-row--support .helpdesk-chat-bubble {
+ background: var(--app-primary);
+ border-color: var(--app-primary);
+ color: var(--app-primary-inverse);
+ }
+ .helpdesk-chat-row--message::before {
+ content: "";
+ position: absolute;
+ left: var(--helpdesk-tl-x);
+ top: -0.45rem;
+ bottom: -0.45rem;
+ width: 1px;
+ background: color-mix(in srgb, var(--app-border, #dee2e6) 88%, transparent);
+ z-index: 0;
+ }
+
+ .helpdesk-chat-row--message::after {
+ content: "";
+ position: absolute;
+ left: calc(var(--helpdesk-tl-x) - (var(--helpdesk-tl-dot) / 2) + 0.5px);
+ top: 0.52rem;
+ width: var(--helpdesk-tl-dot);
+ height: var(--helpdesk-tl-dot);
+ border-radius: 50%;
+ box-sizing: border-box;
+ border: 2px solid
+ color-mix(in srgb, var(--app-border, #dee2e6) 82%, #8f9bae);
+ background: #fff;
+ z-index: 1;
+ }
+
+ .helpdesk-chat-row--message > * {
+ position: relative;
+ z-index: 1;
+ }
+
+ .helpdesk-chat-row--message .helpdesk-chat-meta-block,
+ .helpdesk-chat-row--message .helpdesk-chat-bubble {
+ margin-left: 1.35rem;
+ }
+
+ .helpdesk-chat-activity {
+ --helpdesk-tl-x: 7px;
+ --helpdesk-tl-dot: 10px;
+ position: relative;
+ display: block;
+ padding: 0.1rem 0 0.1rem 1.35rem;
+ margin: 0;
+ }
+
+ .helpdesk-chat-activity::before {
+ content: "";
+ position: absolute;
+ left: var(--helpdesk-tl-x);
+ top: -0.45rem;
+ bottom: -0.45rem;
+ width: 1px;
+ background: linear-gradient(
+ to bottom,
+ transparent 0%,
+ color-mix(in srgb, var(--app-border, #dee2e6) 88%, transparent) 12%,
+ color-mix(in srgb, var(--app-border, #dee2e6) 88%, transparent) 88%,
+ transparent 100%
+ );
+ z-index: 0;
+ }
+
+ .helpdesk-chat-activity::after {
+ content: "";
+ position: absolute;
+ left: calc(var(--helpdesk-tl-x) - (var(--helpdesk-tl-dot) / 2) + 0.5px);
+ top: 0.48rem;
+ width: var(--helpdesk-tl-dot);
+ height: var(--helpdesk-tl-dot);
+ border-radius: 50%;
+ box-sizing: border-box;
+ border: 2px solid
+ color-mix(in srgb, var(--app-border, #dee2e6) 86%, #8f9bae);
+ background: #fff;
+ z-index: 1;
+ }
+
+ .helpdesk-chat-activity-pill {
+ position: relative;
+ z-index: 1;
+ display: block;
+ border-radius: 0;
+ border: 0;
+ border-left: 0;
+ background: transparent;
+ color: color-mix(in srgb, var(--app-muted, #6c757d) 92%, #334155);
+ padding: 0.08rem 0 0.08rem 0;
+ font-size: 0.8rem;
+ font-weight: 500;
+ line-height: 1.4;
+ max-width: 100%;
+ white-space: normal;
+ overflow: visible;
+ text-overflow: clip;
+ overflow-wrap: anywhere;
+ }
+
+ .helpdesk-chat-activity[data-variant="status"] .helpdesk-chat-activity-pill {
+ color: color-mix(in srgb, var(--app-muted, #6c757d) 92%, #334155);
+ }
+
+ .helpdesk-chat-activity[data-variant="forward"] .helpdesk-chat-activity-pill {
+ color: color-mix(in srgb, var(--app-muted, #6c757d) 92%, #334155);
+ }
+
+ .helpdesk-chat-activity[data-variant="file"] .helpdesk-chat-activity-pill {
+ color: color-mix(in srgb, var(--app-muted, #6c757d) 92%, #334155);
+ }
+
+ #app-details-aside-section .helpdesk-chat-row {
+ max-width: 100%;
+ min-width: 0;
+ }
+
+ #app-details-aside-section .helpdesk-chat-time {
+ margin-left: 0;
+ }
+
+ #app-details-aside-section .helpdesk-chat-meta-row {
+ gap: 0.25rem 0.4rem;
+ }
+
+ @media (min-width: 968px) {
+ .app-details-container {
+ overflow-x: hidden;
+ overflow-x: clip;
+ }
+
+ #app-details-aside-section {
+ display: flex;
+ min-width: 0;
+ }
+
+ #app-details-aside-section .helpdesk-comm-aside {
+ display: flex;
+ flex-direction: column;
+ flex: 1;
+ height: 100%;
+ min-height: 0;
+ }
+
+ #app-details-aside-section #debitor-communication-content:not([hidden]) {
+ display: flex;
+ flex: 1;
+ min-height: 0;
+ min-width: 0;
+ }
+
+ #app-details-aside-section #debitor-communication-feed {
+ display: flex;
+ flex-direction: column;
+ flex: 1;
+ min-height: 0;
+ min-width: 0;
+ }
+ }
}
diff --git a/modules/helpdesk/web/js/helpdesk-communication.js b/modules/helpdesk/web/js/helpdesk-communication.js
new file mode 100644
index 0000000..35d5c91
--- /dev/null
+++ b/modules/helpdesk/web/js/helpdesk-communication.js
@@ -0,0 +1,128 @@
+function esc(str) {
+ const d = document.createElement('div');
+ d.textContent = String(str ?? '');
+ return d.innerHTML;
+}
+
+function activityActionLabel(typeVariant, type, stateSubtype, t) {
+ if (typeVariant === 'status') {
+ const base = t('changed status');
+ return stateSubtype ? `${base} (${stateSubtype})` : base;
+ }
+ if (typeVariant === 'forward') {
+ return t('Forwarded');
+ }
+ if (typeVariant === 'file') {
+ return t('attached a file');
+ }
+ if (type && String(type).trim() !== '') {
+ return type;
+ }
+
+ return t('logged an activity');
+}
+
+function entryTimestamp(entry, t) {
+ const date = entry.date || '';
+ const timeRaw = entry.time || '';
+ const time = timeRaw.length >= 5 ? timeRaw.slice(0, 5) : timeRaw;
+ if (date && time) return `${date} ${time}`;
+ if (entry.timestamp_iso) return entry.timestamp_iso;
+ if (date) return date;
+ return t('Unknown time');
+}
+
+function participantRole(entry) {
+ const explicitRole = String(entry.actor_role || '').trim().toLowerCase();
+ if (explicitRole === 'customer' || explicitRole === 'support') {
+ return explicitRole;
+ }
+
+ const actor = String(entry.actor || '').trim().toLowerCase();
+ if (actor.startsWith('kt') || actor.includes('kontakt') || actor.includes('contact')) {
+ return 'customer';
+ }
+
+ return 'support';
+}
+
+function ticketHref(baseUrl, ticketNo) {
+ const base = String(baseUrl || '').trim();
+ const no = String(ticketNo || '').trim();
+ if (!base || !no) return '';
+
+ return `${base}${encodeURIComponent(no)}`;
+}
+
+export function renderCommunicationError(message) {
+ return `
`;
+}
+
+export function renderCommunicationHint(message) {
+ return `
${esc(message)}
`;
+}
+
+export function renderCommunicationFeed(entries, options = {}) {
+ const {
+ t = (k) => k,
+ emptyMessage = 'No communication found.',
+ showTicketNo = false,
+ ticketBaseUrl = '',
+ } = options;
+
+ const list = Array.isArray(entries) ? entries : [];
+ if (list.length === 0) {
+ return `
+
${esc(t(emptyMessage))}
+
`;
+ }
+
+ let html = '
';
+ for (const entry of list) {
+ const actor = entry.actor || t('Unknown user');
+ const typeVariant = entry.type_variant || 'other';
+ const type = entry.type || '';
+ const stateSubtype = entry.state_subtype || '';
+ const message = entry.message || '';
+ const ticketNo = entry.ticket_no || '';
+ const timeLabel = entryTimestamp(entry, t);
+ const ticketUrl = ticketHref(ticketBaseUrl, ticketNo);
+
+ if (typeVariant === 'message') {
+ const role = participantRole(entry);
+ html += `- `;
+ html += '';
+ html += `
${esc(message)}
`;
+ html += ' ';
+ continue;
+ }
+
+ const activityAction = activityActionLabel(typeVariant, type, stateSubtype, t);
+ let activityText = `${actor} ${activityAction}`.trim();
+ if (timeLabel) {
+ activityText += ` ${t('on')} ${timeLabel}`;
+ }
+ if (showTicketNo && ticketNo) {
+ activityText += ` · ${t('Ticket')} ${ticketNo}`;
+ }
+
+ html += `- `;
+ html += `
${esc(activityText)}
`;
+ html += ' ';
+ }
+ html += '
';
+
+ return html;
+}
diff --git a/modules/helpdesk/web/js/helpdesk-detail.js b/modules/helpdesk/web/js/helpdesk-detail.js
index 8d0ee77..555e6e5 100644
--- a/modules/helpdesk/web/js/helpdesk-detail.js
+++ b/modules/helpdesk/web/js/helpdesk-detail.js
@@ -8,6 +8,11 @@
*/
import { initStandardListPage } from '/js/core/app-grid-factory.js';
import { readPageConfig } from '/js/core/app-page-config.js';
+import {
+ renderCommunicationError,
+ renderCommunicationFeed,
+ renderCommunicationHint,
+} from './helpdesk-communication.js';
const container = document.querySelector('.app-details-container[data-customer-no]');
if (container) {
@@ -21,6 +26,7 @@ function init(container) {
const ticketsUrl = container.dataset.ticketsUrl || '';
const ticketBaseUrl = container.dataset.ticketBaseUrl || '';
const summaryUrl = container.dataset.summaryUrl || '';
+ const communicationUrl = container.dataset.communicationUrl || '';
if (!customerNo || !customerName) return;
@@ -34,6 +40,7 @@ function init(container) {
let summaryPromise = null;
let overviewTicketsPromise = null;
let ticketCategoriesPromise = null;
+ let debitorCommunicationPromise = null;
let contactsRendered = { overview: false, full: false };
let ticketsGridInitialized = false;
let ticketsGridInitializing = false;
@@ -111,6 +118,21 @@ function init(container) {
return ticketCategoriesPromise;
}
+ function fetchDebitorCommunication() {
+ if (!debitorCommunicationPromise) {
+ if (!communicationUrl) {
+ debitorCommunicationPromise = Promise.resolve({ ok: false, error: 'No communication endpoint configured' });
+ } else {
+ const params = new URLSearchParams({ customerNo, customerName });
+ debitorCommunicationPromise = fetch(communicationUrl + '?' + params.toString(), { credentials: 'same-origin' })
+ .then(r => r.json())
+ .catch(() => ({ ok: false, error: 'Network error' }));
+ }
+ }
+
+ return debitorCommunicationPromise;
+ }
+
function hydrateTicketCategoryFilter(config, categories) {
const categoryFilter = document.querySelector('#helpdesk-ticket-category-filter');
if (!(categoryFilter instanceof HTMLSelectElement)) return;
@@ -583,6 +605,35 @@ function init(container) {
contactsRendered.full = true;
}
+ async function renderDebitorCommunicationAside() {
+ const feedEl = document.getElementById('debitor-communication-feed');
+ const loadingEl = document.getElementById('debitor-communication-loading');
+ const contentEl = document.getElementById('debitor-communication-content');
+ if (!feedEl || !loadingEl || !contentEl) return;
+
+ showLoading('debitor-communication-loading');
+ const result = await fetchDebitorCommunication();
+ hideLoading('debitor-communication-loading');
+ showContent('debitor-communication-content');
+
+ if (!result.ok) {
+ feedEl.innerHTML = renderCommunicationError(t('Could not load communication history.'));
+ return;
+ }
+
+ let html = '';
+ if ((result.meta?.soap_partial_failures || 0) > 0) {
+ html += renderCommunicationHint(t('Some ticket communications could not be loaded completely.'));
+ }
+ html += renderCommunicationFeed(result.entries || [], {
+ t,
+ emptyMessage: 'No communication found.',
+ showTicketNo: true,
+ ticketBaseUrl,
+ });
+ feedEl.innerHTML = html;
+ }
+
// --- Tab change observer ---
// Watch for tab panel visibility changes to trigger lazy rendering
@@ -607,4 +658,5 @@ function init(container) {
// Start loading immediately — overview is the default tab
renderOverview();
+ renderDebitorCommunicationAside();
}
diff --git a/modules/helpdesk/web/js/helpdesk-ticket.js b/modules/helpdesk/web/js/helpdesk-ticket.js
index fb58fe7..4f1e303 100644
--- a/modules/helpdesk/web/js/helpdesk-ticket.js
+++ b/modules/helpdesk/web/js/helpdesk-ticket.js
@@ -4,6 +4,11 @@
* Loads the ticket activity log (PBI_FP_TicketLog) via fetch and renders
* a chronological timeline of status changes and messages.
*/
+import {
+ renderCommunicationError,
+ renderCommunicationFeed,
+ renderCommunicationHint,
+} from './helpdesk-communication.js';
const container = document.querySelector('.app-details-container[data-ticket-no]');
if (container) {
@@ -13,6 +18,7 @@ if (container) {
function init(container) {
const ticketNo = container.dataset.ticketNo || '';
const logUrl = container.dataset.ticketLogUrl || '';
+ const communicationUrl = container.dataset.ticketCommunicationUrl || '';
if (!ticketNo || !logUrl) return;
@@ -157,5 +163,45 @@ function init(container) {
}
}
+ async function loadCommunication() {
+ const communicationEl = document.getElementById('ticket-communication');
+ const loadingEl = document.getElementById('ticket-communication-loading');
+ if (!communicationEl || !communicationUrl) return;
+
+ try {
+ const params = new URLSearchParams({ ticketNo });
+ const response = await fetch(communicationUrl + '?' + params.toString(), { credentials: 'same-origin' });
+ const data = await response.json();
+
+ if (loadingEl) loadingEl.hidden = true;
+
+ if (!data.ok) {
+ communicationEl.innerHTML = renderCommunicationError(t('Could not load communication history.'));
+ return;
+ }
+
+ let html = '';
+ if (
+ data.meta
+ && data.meta.soap_used === false
+ && data.meta.fallback_used === true
+ && data.meta.soap_error
+ ) {
+ html += renderCommunicationHint(t('Live communication unavailable, showing activity fallback.'));
+ }
+ html += renderCommunicationFeed(data.entries || [], {
+ t,
+ emptyMessage: 'No communication found.',
+ showTicketNo: false,
+ });
+
+ communicationEl.innerHTML = html;
+ } catch {
+ if (loadingEl) loadingEl.hidden = true;
+ communicationEl.innerHTML = renderCommunicationError(t('Could not load communication history.'));
+ }
+ }
+
loadTimeline();
+ loadCommunication();
}