1
0
Files
breadcrumb-the-shire/modules/helpdesk/lib/Module/Helpdesk/Service/TicketCommunicationService.php
fs 792f706d9e feat(helpdesk): add ticket file download and inline image preview
New SOAP method getTicketFile() on BcSoapGateway calls BC's
GetTicketFile to retrieve file attachments as Base64, decodes in
memory and returns binary data without writing to disk.

New proxy endpoint helpdesk/ticket-file-data streams files directly
to the browser with correct MIME type. Images served inline, other
files as download attachment.

TicketCommunicationService now attaches file_entry_no and file_name
to file-type events. Frontend renders download links with bi-download
icon and inline image previews for jpg/png/gif attachments.

Works in both ticket detail and debitor detail communication feeds.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 22:49:01 +02:00

1114 lines
37 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,
'file_entry_no' => $typeVariant === 'file' ? $entryNo : '',
'file_name' => $typeVariant === 'file' ? $this->extractFilenameFromMessage($message, $recordId) : '',
'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;
}
/**
* Extract a filename from the message or record ID of a file-type log entry.
*/
private function extractFilenameFromMessage(string $message, string $recordId): string
{
// Message itself may contain the filename
$trimmed = trim($message);
if ($trimmed !== '' && str_contains($trimmed, '.') && !str_contains($trimmed, ' ')) {
return $trimmed;
}
// Record_ID sometimes contains the filename
$recordTrimmed = trim($recordId);
if ($recordTrimmed !== '' && str_contains($recordTrimmed, '.')) {
// Extract last segment after colon or space that looks like a filename
if (preg_match('/([^\s:]+\.\w{2,5})$/i', $recordTrimmed, $m)) {
return $m[1];
}
}
return $trimmed !== '' ? $trimmed : '';
}
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';
}
}