1
0
Files
breadcrumb-the-shire/modules/helpdesk/lib/Module/Helpdesk/Service/TicketCommunicationService.php
fs fbe8140937 fix(helpdesk): drop redundant SOAP message entries when OData has them
When the SOAP message map is applied to OData entries, the message text
is already embedded in the OData entries. The separate SOAP message
entries then cause duplicates with different actor/timestamp metadata.

Now: when soapMessageMap was used, filter out SOAP entries with
type_variant 'message' since they are already represented in OData.
Non-message SOAP entries (status, activity) are kept.

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

1028 lines
34 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 [];
}
$result = [];
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) {
$result[$actor] = $supportUserName;
}
}
return $result;
}
/**
* 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 [];
}
// Map support actor codes to names using ticket assignment data.
// For each entry with a support role, look up the ticket's Support_User_Name
// and map the actor code to that name. This handles tickets with multiple
// support codes (e.g. after reassignment) robustly.
$result = [];
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;
}
$supportUserName = $supportUserNamesByTicket[$ticketNo] ?? '';
if ($supportUserName !== '' && !isset($result[$actor])) {
$result[$actor] = $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';
}
}