forked from fa/breadcrumb-the-shire
When a ticket was reassigned (multiple support codes), all codes were mapped to the current Support_User_Name — replacing the previous agent's name with the new one on older entries. Now: if multiple support codes exist, only the code from the most recent entry is mapped to the current name. Older codes remain as abbreviations, preserving the actual history of who worked on what. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1089 lines
36 KiB
PHP
1089 lines
36 KiB
PHP
<?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 $actorNameMap = [],
|
|
bool $allowPerTicketActorLookup = true
|
|
): 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'];
|
|
$soapEntries = [];
|
|
$soapMessageMap = [];
|
|
if ($soapResult['ok'] === 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);
|
|
}
|
|
|
|
// When OData entries are enriched with SOAP message text via soapMessageMap,
|
|
// the separate SOAP entries for those messages are redundant and cause
|
|
// duplicates. Drop SOAP message entries when the map was applied.
|
|
if ($soapMessageMap !== [] && $soapEntries !== []) {
|
|
$soapEntries = array_values(array_filter(
|
|
$soapEntries,
|
|
static fn (array $e): bool => ($e['type_variant'] ?? '') !== 'message'
|
|
));
|
|
}
|
|
|
|
if ($actorNameMap !== []) {
|
|
$odataEntries = $this->applyActorNameMap($odataEntries, $actorNameMap);
|
|
$soapEntries = $this->applyActorNameMap($soapEntries, $actorNameMap);
|
|
} elseif ($allowPerTicketActorLookup) {
|
|
$ticket = null;
|
|
try {
|
|
$ticket = $this->bcODataGateway->getTicket($ticketNo);
|
|
} catch (\Throwable) {
|
|
// ignore
|
|
}
|
|
$resolvedActorNameMap = $this->resolveContactActorNames($ticket, $odataEntries, $soapEntries);
|
|
$supportActorNameMap = $this->resolveSupportActorNames($ticket, $odataEntries, $soapEntries);
|
|
$combinedMap = array_merge($resolvedActorNameMap, $supportActorNameMap);
|
|
if ($combinedMap !== []) {
|
|
$odataEntries = $this->applyActorNameMap($odataEntries, $combinedMap);
|
|
$soapEntries = $this->applyActorNameMap($soapEntries, $combinedMap);
|
|
}
|
|
}
|
|
|
|
$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;
|
|
$actorNameMap = $this->buildContactActorNameMapForDebitor($customerName);
|
|
|
|
// Collect support user name → ticket code mappings
|
|
$supportUserNamesByTicket = [];
|
|
foreach ($tickets as $ticket) {
|
|
if (!is_array($ticket)) {
|
|
continue;
|
|
}
|
|
$no = trim((string) ($ticket['No'] ?? ''));
|
|
$name = trim((string) ($ticket['Support_User_Name'] ?? ''));
|
|
if ($no !== '' && $name !== '') {
|
|
$supportUserNamesByTicket[$no] = $name;
|
|
}
|
|
}
|
|
|
|
foreach (array_keys($ticketsToProcess) as $ticketNo) {
|
|
$feed = $this->loadTicketCommunicationFeed(
|
|
$ticketNo,
|
|
$maxEntriesPerTicket,
|
|
$actorNameMap,
|
|
false
|
|
);
|
|
$ticketEntries = [];
|
|
if ($feed['ok']) {
|
|
$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++;
|
|
}
|
|
}
|
|
|
|
// Build support code→name map from entries + ticket data, then apply
|
|
$supportActorNameMap = $this->buildSupportActorNameMapFromEntries($allEntries, $supportUserNamesByTicket);
|
|
if ($supportActorNameMap !== []) {
|
|
$allEntries = $this->applyActorNameMap($allEntries, $supportActorNameMap);
|
|
}
|
|
|
|
$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 = $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['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<string, mixed>|null $ticket Pre-fetched ticket data
|
|
* @param array<int, array<string, string>> $odataEntries
|
|
* @param array<int, array<string, string>> $soapEntries
|
|
* @return array<string, string>
|
|
*/
|
|
private function resolveContactActorNames(?array $ticket, array $odataEntries, array $soapEntries): array
|
|
{
|
|
$contactActorIds = $this->extractContactActorIds([...$odataEntries, ...$soapEntries]);
|
|
if ($contactActorIds === []) {
|
|
return [];
|
|
}
|
|
|
|
$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;
|
|
}
|
|
|
|
/**
|
|
* @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;
|
|
}
|
|
|
|
/**
|
|
* Resolve support user actor codes to full names for a single ticket.
|
|
*
|
|
* Strategy: use the pre-fetched ticket's Support_User_Name, then look at
|
|
* which support-type Created_By codes appear in the log entries. If there
|
|
* is exactly one unique support code, map it to the Support_User_Name.
|
|
*
|
|
* @param array<string, mixed>|null $ticket Pre-fetched ticket data
|
|
* @param array<int, array<string, string>> $odataEntries
|
|
* @param array<int, array<string, string>> $soapEntries
|
|
* @return array<string, string>
|
|
*/
|
|
private function resolveSupportActorNames(?array $ticket, array $odataEntries, array $soapEntries): array
|
|
{
|
|
$supportUserName = trim((string) ($ticket['Support_User_Name'] ?? ''));
|
|
if ($supportUserName === '') {
|
|
return [];
|
|
}
|
|
|
|
// Collect all unique support actor codes
|
|
$codes = [];
|
|
foreach ([...$odataEntries, ...$soapEntries] as $entry) {
|
|
$actorRole = trim((string) ($entry['actor_role'] ?? ''));
|
|
if ($actorRole !== 'support') {
|
|
continue;
|
|
}
|
|
$actor = strtoupper(trim((string) ($entry['actor'] ?? '')));
|
|
if ($actor !== '' && !$this->isContactActorId($actor) && mb_strlen($actor) <= 10) {
|
|
$codes[$actor] = true;
|
|
}
|
|
}
|
|
|
|
if ($codes === []) {
|
|
return [];
|
|
}
|
|
|
|
// If only one support code exists, it must be the current user
|
|
if (count($codes) === 1) {
|
|
return [array_keys($codes)[0] => $supportUserName];
|
|
}
|
|
|
|
// Multiple codes (ticket was reassigned): find the code that appears
|
|
// in the most recent entry — that's most likely the current assignee.
|
|
// Only map that code; leave older codes as abbreviations.
|
|
$latestCode = null;
|
|
$latestTs = '';
|
|
foreach ([...$odataEntries, ...$soapEntries] as $entry) {
|
|
$actorRole = trim((string) ($entry['actor_role'] ?? ''));
|
|
if ($actorRole !== 'support') {
|
|
continue;
|
|
}
|
|
$actor = strtoupper(trim((string) ($entry['actor'] ?? '')));
|
|
if (!isset($codes[$actor])) {
|
|
continue;
|
|
}
|
|
$ts = trim((string) ($entry['timestamp_iso'] ?? ''));
|
|
if ($ts > $latestTs) {
|
|
$latestTs = $ts;
|
|
$latestCode = $actor;
|
|
}
|
|
}
|
|
|
|
if ($latestCode !== null) {
|
|
return [$latestCode => $supportUserName];
|
|
}
|
|
|
|
return [];
|
|
}
|
|
|
|
/**
|
|
* Build a support user code→name map from already-fetched communication entries.
|
|
*
|
|
* Groups entries by ticket_no, finds unique support-type actor codes per
|
|
* ticket, and maps each code to the ticket's Support_User_Name (from the
|
|
* pre-built $supportUserNamesByTicket lookup). No additional API calls.
|
|
*
|
|
* @param array<int, array<string, string>> $entries
|
|
* @param array<string, string> $supportUserNamesByTicket ticket_no → Support_User_Name
|
|
* @return array<string, string> code → full name
|
|
*/
|
|
private function buildSupportActorNameMapFromEntries(array $entries, array $supportUserNamesByTicket): array
|
|
{
|
|
if ($entries === [] || $supportUserNamesByTicket === []) {
|
|
return [];
|
|
}
|
|
|
|
// Group support actor codes per ticket with their latest timestamp.
|
|
// For tickets with a single code, map it directly. For tickets with
|
|
// multiple codes (reassigned), only map the most recent one.
|
|
/** @var array<string, array<string, string>> $codesByTicket ticket_no → [CODE → latest_ts] */
|
|
$codesByTicket = [];
|
|
foreach ($entries as $entry) {
|
|
$actorRole = trim((string) ($entry['actor_role'] ?? ''));
|
|
if ($actorRole !== 'support') {
|
|
continue;
|
|
}
|
|
$actor = strtoupper(trim((string) ($entry['actor'] ?? '')));
|
|
if ($actor === '' || $this->isContactActorId($actor) || mb_strlen($actor) > 10) {
|
|
continue;
|
|
}
|
|
$ticketNo = trim((string) ($entry['ticket_no'] ?? ''));
|
|
if ($ticketNo === '') {
|
|
continue;
|
|
}
|
|
$ts = trim((string) ($entry['timestamp_iso'] ?? ''));
|
|
if (!isset($codesByTicket[$ticketNo][$actor]) || $ts > $codesByTicket[$ticketNo][$actor]) {
|
|
$codesByTicket[$ticketNo][$actor] = $ts;
|
|
}
|
|
}
|
|
|
|
$result = [];
|
|
foreach ($codesByTicket as $ticketNo => $codes) {
|
|
$supportUserName = $supportUserNamesByTicket[$ticketNo] ?? '';
|
|
if ($supportUserName === '') {
|
|
continue;
|
|
}
|
|
|
|
if (count($codes) === 1) {
|
|
$result[array_keys($codes)[0]] = $supportUserName;
|
|
continue;
|
|
}
|
|
|
|
// Multiple codes: map only the most recent one
|
|
$latestCode = null;
|
|
$latestTs = '';
|
|
foreach ($codes as $code => $ts) {
|
|
if ($ts > $latestTs) {
|
|
$latestTs = $ts;
|
|
$latestCode = $code;
|
|
}
|
|
}
|
|
if ($latestCode !== null && !isset($result[$latestCode])) {
|
|
$result[$latestCode] = $supportUserName;
|
|
}
|
|
}
|
|
|
|
return $result;
|
|
}
|
|
|
|
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';
|
|
}
|
|
}
|