1
0
Files
breadcrumb-the-shire/modules/helpdesk/lib/Module/Helpdesk/Service/TicketCommunicationService.php
fs 422746d141 fix(helpdesk): resolve all support actor codes instead of requiring exactly one per ticket
The previous logic only resolved support user codes to full names when
exactly one unique support code existed per ticket. Tickets with multiple
support codes (e.g. after reassignment) left all codes unresolved,
showing abbreviations like 'NKS' or 'FA' instead of full names.

Now: every support-role actor code in a ticket is mapped to that ticket's
Support_User_Name. For the debitor feed, codes are mapped on first
encounter to avoid conflicts from reassigned tickets.

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

1019 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);
}
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['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<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';
}
}