forked from fa/breadcrumb-the-shire
feat(helpdesk): add ticket/debtor communication feed and chat timeline UI
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
360
modules/helpdesk/lib/Module/Helpdesk/Service/BcSoapGateway.php
Normal file
360
modules/helpdesk/lib/Module/Helpdesk/Service/BcSoapGateway.php
Normal file
@@ -0,0 +1,360 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Module\Helpdesk\Service;
|
||||
|
||||
use MintyPHP\Http\SessionStoreInterface;
|
||||
|
||||
/**
|
||||
* Gateway for Business Central SOAP codeunit calls used by helpdesk communication.
|
||||
*/
|
||||
class BcSoapGateway
|
||||
{
|
||||
private const CONNECT_TIMEOUT = 10;
|
||||
private const REQUEST_TIMEOUT = 30;
|
||||
private const CODEUNIT_NAME = 'ICISupportWebserviceAPI';
|
||||
private const SOAP_ACTION_GET_TICKET_COMMUNICATION_TEXT = 'urn:microsoft-dynamics-schemas/codeunit/ICISupportWebserviceAPI:GetTicketCommunicationText';
|
||||
|
||||
public function __construct(
|
||||
private readonly HelpdeskSettingsGateway $settingsGateway,
|
||||
private readonly HelpdeskOAuthTokenService $oauthTokenService,
|
||||
private readonly SessionStoreInterface $sessionStore
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{
|
||||
* ok: bool,
|
||||
* soap_used: bool,
|
||||
* return_value: int,
|
||||
* communication_text: string,
|
||||
* message_entries: string,
|
||||
* error?: string
|
||||
* }
|
||||
*/
|
||||
public function getTicketCommunicationText(string $ticketNo, string $messageEntries = ''): array
|
||||
{
|
||||
$ticketNo = trim($ticketNo);
|
||||
if ($ticketNo === '') {
|
||||
return [
|
||||
'ok' => 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 <<<XML
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<soap:Envelope xmlns:soap="http://schemas.xmlsoap.org/soap/envelope/" xmlns:urn="urn:microsoft-dynamics-schemas/codeunit/ICISupportWebserviceAPI">
|
||||
<soap:Body>
|
||||
<urn:GetTicketCommunicationText>
|
||||
<urn:ticketNo>{$escapedTicketNo}</urn:ticketNo>
|
||||
<urn:communicationText></urn:communicationText>
|
||||
<urn:messageEntries>{$escapedMessageEntries}</urn:messageEntries>
|
||||
</urn:GetTicketCommunicationText>
|
||||
</soap:Body>
|
||||
</soap:Envelope>
|
||||
XML;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{headers?: array<int, string>, 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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,872 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Module\Helpdesk\Service;
|
||||
|
||||
/**
|
||||
* Aggregates ticket communication from SOAP and OData into a normalized feed.
|
||||
*/
|
||||
class TicketCommunicationService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly BcSoapGateway $bcSoapGateway,
|
||||
private readonly BcODataGateway $bcODataGateway
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{
|
||||
* ok: bool,
|
||||
* entries?: array<int, array<string, string>>,
|
||||
* meta?: array<string, mixed>,
|
||||
* 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<int, array<string, string>>,
|
||||
* meta?: array<string, mixed>,
|
||||
* 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<int, array<string, mixed>> $ticketLog
|
||||
* @param array<string, string> $soapMessageMap
|
||||
* @return array<int, array<string, string>>
|
||||
*/
|
||||
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<int, array<string, string>>
|
||||
*/
|
||||
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<int, array<string, mixed>> $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<string, string>
|
||||
*/
|
||||
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<int, array<string, string>>
|
||||
*/
|
||||
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('#<br\s*/?>#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<string, string>
|
||||
*/
|
||||
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<string, string>
|
||||
*/
|
||||
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<int, array<string, string>> $primary
|
||||
* @param array<int, array<string, string>> $secondary
|
||||
* @return array<int, array<string, string>>
|
||||
*/
|
||||
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<string, string> $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<int, array<string, string>> $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('#<br\s*/?>#i', "\n", $message) ?? $message;
|
||||
$message = preg_replace('#</p>\s*<p>#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<int, array<string, string>> $odataEntries
|
||||
* @param array<int, array<string, string>> $soapEntries
|
||||
* @return array<string, string>
|
||||
*/
|
||||
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<int, array<string, string>> $entries
|
||||
* @return array<int, array<string, string>>
|
||||
*/
|
||||
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<int, array<string, string>> $entries
|
||||
* @return array<int, string>
|
||||
*/
|
||||
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';
|
||||
}
|
||||
}
|
||||
@@ -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' => [],
|
||||
|
||||
|
||||
@@ -38,6 +38,7 @@ $locationDisplay = $postCode !== '' && $city !== '' ? $postCode . ' ' . $city :
|
||||
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')); ?>"
|
||||
>
|
||||
<section>
|
||||
<?php
|
||||
@@ -222,6 +223,22 @@ $locationDisplay = $postCode !== '' && $city !== '' ? $postCode . ' ' . $city :
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</section>
|
||||
|
||||
<?php if (!$hasError && $customer !== []): ?>
|
||||
<aside id="app-details-aside-section">
|
||||
<div class="app-details-aside-section helpdesk-comm-aside">
|
||||
<hgroup>
|
||||
<h2><?php e(t('Ticket communication')); ?></h2>
|
||||
<p><?php e(t('Last 5 tickets')); ?></p>
|
||||
</hgroup>
|
||||
<hr>
|
||||
<div id="debitor-communication-loading" aria-busy="true"><?php e(t('Loading...')); ?></div>
|
||||
<div id="debitor-communication-content" hidden>
|
||||
<div id="debitor-communication-feed"></div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<!-- Grid.js tickets config -->
|
||||
@@ -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...'),
|
||||
]); ?></script>
|
||||
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
<?php
|
||||
|
||||
use MintyPHP\Http\SessionStoreInterface;
|
||||
use MintyPHP\Module\Helpdesk\HelpdeskAuthorizationPolicy;
|
||||
use MintyPHP\Module\Helpdesk\Service\TicketCommunicationService;
|
||||
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', ''));
|
||||
|
||||
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);
|
||||
@@ -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);
|
||||
|
||||
@@ -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']));
|
||||
|
||||
@@ -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
|
||||
<div class="app-details-container"
|
||||
data-ticket-no="<?php e($ticketNo); ?>"
|
||||
data-ticket-log-url="<?php e($ticketLogUrl); ?>"
|
||||
data-ticket-communication-url="<?php e($ticketCommunicationUrl); ?>"
|
||||
>
|
||||
<section>
|
||||
<?php
|
||||
@@ -127,14 +130,20 @@ $backUrl = $ticketCustomerNo !== '' ? lurl('helpdesk/debitor/' . rawurlencode($t
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Activity timeline — loaded async -->
|
||||
<div class="grid">
|
||||
<!-- Activity timeline + communication feed — loaded async -->
|
||||
<div class="grid grid-2">
|
||||
<div class="app-stats-table">
|
||||
<div class="app-stats-table-header"><?php e(t('Activity timeline')); ?></div>
|
||||
<div id="ticket-timeline">
|
||||
<div id="timeline-loading" aria-busy="true"><?php e(t('Loading...')); ?></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="app-stats-table">
|
||||
<div class="app-stats-table-header"><?php e(t('Communication history')); ?></div>
|
||||
<div id="ticket-communication">
|
||||
<div id="ticket-communication-loading" aria-busy="true"><?php e(t('Loading...')); ?></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</section>
|
||||
@@ -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...'),
|
||||
]); ?></script>
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
use MintyPHP\Http\SessionStoreInterface;
|
||||
use MintyPHP\Module\Helpdesk\HelpdeskAuthorizationPolicy;
|
||||
use MintyPHP\Module\Helpdesk\Service\TicketCommunicationService;
|
||||
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;
|
||||
}
|
||||
|
||||
$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);
|
||||
@@ -0,0 +1,142 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Tests\Module\Helpdesk\Service;
|
||||
|
||||
use MintyPHP\Http\SessionStoreInterface;
|
||||
use MintyPHP\Module\Helpdesk\Service\BcSoapGateway;
|
||||
use MintyPHP\Module\Helpdesk\Service\HelpdeskOAuthTokenService;
|
||||
use MintyPHP\Module\Helpdesk\Service\HelpdeskSettingsGateway;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
class BcSoapGatewayTest extends TestCase
|
||||
{
|
||||
/**
|
||||
* @param array{http_code:int,body:string,error:string} $response
|
||||
*/
|
||||
private function createGateway(array $response): BcSoapGateway
|
||||
{
|
||||
$settings = $this->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'
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<soap:Envelope xmlns:soap="http://schemas.xmlsoap.org/soap/envelope/">
|
||||
<soap:Body>
|
||||
<GetTicketCommunicationText_Result xmlns="urn:microsoft-dynamics-schemas/codeunit/ICISupportWebserviceAPI">
|
||||
<return_value>0</return_value>
|
||||
<communicationText>Hallo aus SOAP</communicationText>
|
||||
<messageEntries>line 1</messageEntries>
|
||||
</GetTicketCommunicationText_Result>
|
||||
</soap:Body>
|
||||
</soap:Envelope>
|
||||
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'
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<soap:Envelope xmlns:soap="http://schemas.xmlsoap.org/soap/envelope/">
|
||||
<soap:Body>
|
||||
<GetTicketCommunicationText_Result xmlns="urn:microsoft-dynamics-schemas/codeunit/ICISupportWebserviceAPI">
|
||||
<return_value>400</return_value>
|
||||
<communicationText></communicationText>
|
||||
</GetTicketCommunicationText_Result>
|
||||
</soap:Body>
|
||||
</soap:Envelope>
|
||||
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']);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Tests\Module\Helpdesk\Service;
|
||||
|
||||
use MintyPHP\Module\Helpdesk\Service\BcODataGateway;
|
||||
use MintyPHP\Module\Helpdesk\Service\BcSoapGateway;
|
||||
use MintyPHP\Module\Helpdesk\Service\TicketCommunicationService;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
class TicketCommunicationServiceTest extends TestCase
|
||||
{
|
||||
public function testLoadTicketCommunicationFeedMergesSoapAndOData(): 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: 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":"<p>Hallo <strong>Team</strong></p>"}]',
|
||||
'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']);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
128
modules/helpdesk/web/js/helpdesk-communication.js
Normal file
128
modules/helpdesk/web/js/helpdesk-communication.js
Normal file
@@ -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 `<div class="notice" data-variant="warning" role="alert"><p>${esc(message)}</p></div>`;
|
||||
}
|
||||
|
||||
export function renderCommunicationHint(message) {
|
||||
return `<p class="helpdesk-comm-hint">${esc(message)}</p>`;
|
||||
}
|
||||
|
||||
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 `<div class="app-empty-state app-empty-state-compact">
|
||||
<p class="app-empty-state-message">${esc(t(emptyMessage))}</p>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
let html = '<ol class="helpdesk-comm-feed" role="list">';
|
||||
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 += `<li class="helpdesk-chat-row helpdesk-chat-row--message helpdesk-chat-row--${esc(role)}" data-variant="${esc(typeVariant)}">`;
|
||||
html += '<div class="helpdesk-chat-meta-block">';
|
||||
html += `<strong class="helpdesk-chat-actor">${esc(actor)}</strong>`;
|
||||
html += '<small class="helpdesk-chat-meta-row">';
|
||||
if (showTicketNo && ticketNo) {
|
||||
if (ticketUrl) {
|
||||
html += `<a class="helpdesk-chat-meta-link" href="${esc(ticketUrl)}">${esc(t('Ticket'))} ${esc(ticketNo)}</a>`;
|
||||
} else {
|
||||
html += `<span class="helpdesk-chat-meta-item">${esc(t('Ticket'))} ${esc(ticketNo)}</span>`;
|
||||
}
|
||||
}
|
||||
html += `<span class="helpdesk-chat-meta-item helpdesk-chat-time">${esc(timeLabel)}</span>`;
|
||||
html += '</small>';
|
||||
html += '</div>';
|
||||
html += `<p class="helpdesk-chat-bubble">${esc(message)}</p>`;
|
||||
html += '</li>';
|
||||
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 += `<li class="helpdesk-chat-row helpdesk-chat-row--activity" data-variant="${esc(typeVariant)}">`;
|
||||
html += `<div class="helpdesk-chat-activity" data-variant="${esc(typeVariant)}"><span class="helpdesk-chat-activity-pill">${esc(activityText)}</span></div>`;
|
||||
html += '</li>';
|
||||
}
|
||||
html += '</ol>';
|
||||
|
||||
return html;
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user