2026-04-03 16:45:41 +02:00
|
|
|
<?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
|
|
|
|
|
* }
|
|
|
|
|
*/
|
2026-04-04 18:34:03 +02:00
|
|
|
public function loadTicketCommunicationFeed(
|
|
|
|
|
string $ticketNo,
|
|
|
|
|
int $maxEntries = 40,
|
|
|
|
|
array $actorNameMap = [],
|
|
|
|
|
bool $allowPerTicketActorLookup = true
|
|
|
|
|
): array {
|
2026-04-03 16:45:41 +02:00
|
|
|
$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'] ?? '');
|
2026-04-04 18:34:03 +02:00
|
|
|
$soapUsed = (bool) $soapResult['soap_used'];
|
2026-04-03 16:45:41 +02:00
|
|
|
$soapEntries = [];
|
|
|
|
|
$soapMessageMap = [];
|
2026-04-04 18:34:03 +02:00
|
|
|
if ($soapResult['ok'] === true) {
|
2026-04-03 16:45:41 +02:00
|
|
|
$soapEntries = $this->parseSoapEntries(
|
|
|
|
|
$ticketNo,
|
2026-04-04 18:34:03 +02:00
|
|
|
(string) $soapResult['communication_text'],
|
|
|
|
|
(string) $soapResult['message_entries']
|
2026-04-03 16:45:41 +02:00
|
|
|
);
|
|
|
|
|
$soapMessageMap = $this->extractSoapMessageMap(
|
2026-04-04 18:34:03 +02:00
|
|
|
(string) $soapResult['communication_text'],
|
|
|
|
|
(string) $soapResult['message_entries']
|
2026-04-03 16:45:41 +02:00
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if ($ticketLog !== []) {
|
|
|
|
|
$odataEntries = $this->mapODataEntries($ticketNo, $ticketLog, $soapMessageMap);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if ($actorNameMap !== []) {
|
|
|
|
|
$odataEntries = $this->applyActorNameMap($odataEntries, $actorNameMap);
|
|
|
|
|
$soapEntries = $this->applyActorNameMap($soapEntries, $actorNameMap);
|
2026-04-04 18:34:03 +02:00
|
|
|
} elseif ($allowPerTicketActorLookup) {
|
|
|
|
|
$resolvedActorNameMap = $this->resolveContactActorNames($ticketNo, $odataEntries, $soapEntries);
|
|
|
|
|
if ($resolvedActorNameMap !== []) {
|
|
|
|
|
$odataEntries = $this->applyActorNameMap($odataEntries, $resolvedActorNameMap);
|
|
|
|
|
$soapEntries = $this->applyActorNameMap($soapEntries, $resolvedActorNameMap);
|
|
|
|
|
}
|
2026-04-03 16:45:41 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$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;
|
2026-04-04 18:34:03 +02:00
|
|
|
$actorNameMap = $this->buildContactActorNameMapForDebitor($customerName);
|
2026-04-03 16:45:41 +02:00
|
|
|
|
|
|
|
|
foreach (array_keys($ticketsToProcess) as $ticketNo) {
|
2026-04-04 18:34:03 +02:00
|
|
|
$feed = $this->loadTicketCommunicationFeed(
|
|
|
|
|
$ticketNo,
|
|
|
|
|
$maxEntriesPerTicket,
|
|
|
|
|
$actorNameMap,
|
|
|
|
|
false
|
|
|
|
|
);
|
2026-04-03 16:45:41 +02:00
|
|
|
$ticketEntries = [];
|
2026-04-04 18:34:03 +02:00
|
|
|
if ($feed['ok']) {
|
2026-04-03 16:45:41 +02:00
|
|
|
$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];
|
2026-04-04 18:34:03 +02:00
|
|
|
$time = $this->normalizeTime($m[2]);
|
2026-04-03 16:45:41 +02:00
|
|
|
$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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-04 18:34:03 +02:00
|
|
|
/**
|
|
|
|
|
* @return array<string, string>
|
|
|
|
|
*/
|
|
|
|
|
private function buildContactActorNameMapForDebitor(string $customerName): array
|
|
|
|
|
{
|
|
|
|
|
$customerName = trim($customerName);
|
|
|
|
|
if ($customerName === '') {
|
|
|
|
|
return [];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
$contacts = $this->bcODataGateway->getContactsForCustomer('', $customerName);
|
|
|
|
|
} catch (\Throwable) {
|
|
|
|
|
return [];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$nameMap = [];
|
|
|
|
|
foreach ($contacts as $contact) {
|
|
|
|
|
if (!is_array($contact)) {
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
$contactNo = strtoupper(trim((string) ($contact['No'] ?? '')));
|
|
|
|
|
$contactName = trim((string) ($contact['Name'] ?? ''));
|
|
|
|
|
if ($contactNo === '' || $contactName === '') {
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
$nameMap[$contactNo] = $contactName;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return $nameMap;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-03 16:45:41 +02:00
|
|
|
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';
|
|
|
|
|
}
|
|
|
|
|
}
|