Files
breadcrumb-the-shire/modules/helpdesk/lib/Module/Helpdesk/Service/TicketCommunicationService.php
fs 7220aa7459 feat(helpdesk): add ticket grid filters and resolve support user names in chat
Add 3 new ticket list filters: Support User (dynamic select), Contact (dynamic
select), and Period (30/90/180/365 days). Extend categories endpoint to also
return support_users and contacts arrays from the same cached ticket data.
Add server-side filtering for all three in the data endpoint.

Resolve support user abbreviation codes to full names in the communication chat
widget. For single-ticket view, infer code-to-name from Support_User_Name when
only one support code appears. For debitor view, build map from already-fetched
entries across tickets with no additional API calls.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-05 16:37:07 +02:00

1032 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 [];
}
$supportCodes = [];
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) {
$supportCodes[$actor] = true;
}
}
if (count($supportCodes) !== 1) {
return [];
}
$code = array_keys($supportCodes)[0];
return [$code => $supportUserName];
}
/**
* 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
$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;
}
$codesByTicket[$ticketNo][$actor] = true;
}
$result = [];
foreach ($codesByTicket as $ticketNo => $codes) {
if (count($codes) !== 1) {
continue;
}
$supportUserName = $supportUserNamesByTicket[$ticketNo] ?? '';
if ($supportUserName === '') {
continue;
}
$code = array_keys($codes)[0];
$result[$code] = $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';
}
}