diff --git a/app/Services/Glpi/GlpiClient.php b/app/Services/Glpi/GlpiClient.php index ce7a2be..6245b2c 100644 --- a/app/Services/Glpi/GlpiClient.php +++ b/app/Services/Glpi/GlpiClient.php @@ -2,6 +2,8 @@ namespace App\Services\Glpi; +use Illuminate\Support\Arr; +use Illuminate\Support\Collection; use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Log; @@ -104,7 +106,11 @@ class GlpiClient // engine; excluding a value only works via a negated AND link. ['link' => 'AND NOT', 'field' => 12, 'searchtype' => 'equals', 'value' => self::STATUS_CLOSED], ], - 'forcedisplay' => [2, 1, 12, 3, 7, 21, 19], + // 4 = demandeur, 27 = nombre de suivis, 28 = nombre de tâches (les + // techniciens répondent parfois via une tâche plutôt qu'un suivi — + // les deux comptent comme des interactions), 95 = technicien + // responsable, 5 = technicien assigné (repli si 95 n'est pas renseigné). + 'forcedisplay' => [2, 1, 12, 3, 4, 7, 21, 27, 28, 95, 5, 19], 'sort' => 19, 'order' => 'DESC', 'range' => '0-'.($limit - 1), @@ -116,18 +122,51 @@ class GlpiClient $rows = $response->json('data') ?? []; - $tickets = collect($rows)->map(fn (array $row) => [ - 'id' => $row['2'], - 'title' => $this->decodeText((string) $row['1']), - 'status' => (int) $row['12'], - 'status_label' => self::STATUSES[(int) $row['12']] ?? 'Inconnu', - 'category' => filled($row['7'] ?? null) ? $this->decodeText((string) $row['7']) : null, - 'excerpt' => $this->excerpt($row['21'] ?? null), - 'priority' => (int) $row['3'], - 'priority_label' => self::PRIORITIES[(int) $row['3']] ?? 'Inconnue', - 'date_mod' => $row['19'], - 'url' => $this->ticketUrl((int) $row['2']), - ])->all(); + $technicianIds = collect($rows) + ->flatMap(fn (array $row) => [ + ...Arr::wrap($row['95'] ?? null), + ...Arr::wrap($row['5'] ?? null), + ]) + ->filter() + ->unique() + ->values(); + + $technicianNames = $this->resolveUserNames($sessionToken, $technicianIds); + $lastInteractionAuthors = $this->fetchLastInteractionAuthors($sessionToken, $rows); + + $tickets = collect($rows)->map(function (array $row) use ($technicianNames, $lastInteractionAuthors) { + $technicianIds = filled($row['95'] ?? null) ? Arr::wrap($row['95']) : Arr::wrap($row['5'] ?? null); + $followupsCount = (int) ($row['27'] ?? 0); + $tasksCount = (int) ($row['28'] ?? 0); + $requesterIds = array_map('strval', Arr::wrap($row['4'] ?? null)); + + // Nothing yet means the only thing on the ticket is the + // requester's own opening message — nobody from support has + // replied (via a followup or a task). Otherwise, check who + // wrote whichever of those came in last. + $lastAuthor = ($followupsCount === 0 && $tasksCount === 0) + ? Arr::first($requesterIds) + : ($lastInteractionAuthors[(string) $row['2']] ?? null); + + return [ + 'id' => $row['2'], + 'title' => $this->decodeText((string) $row['1']), + 'status' => (int) $row['12'], + 'status_label' => self::STATUSES[(int) $row['12']] ?? 'Inconnu', + 'category' => filled($row['7'] ?? null) ? $this->decodeText((string) $row['7']) : null, + 'excerpt' => $this->excerpt($row['21'] ?? null), + 'priority' => (int) $row['3'], + 'priority_label' => self::PRIORITIES[(int) $row['3']] ?? 'Inconnue', + 'interactions_count' => $followupsCount + $tasksCount, + 'awaiting_technician' => $lastAuthor !== null && in_array((string) $lastAuthor, $requesterIds, true), + 'technician' => collect($technicianIds) + ->map(fn ($id) => $technicianNames[$id] ?? null) + ->filter() + ->implode(', ') ?: null, + 'date_mod' => $row['19'], + 'url' => $this->ticketUrl((int) $row['2']), + ]; + })->all(); return [ 'tickets' => $tickets, @@ -137,6 +176,123 @@ class GlpiClient }); } + /** + * The search engine can count followups (option 27) and tasks (option 28) + * but doesn't expose who wrote the most recent one of either — and + * technicians in this GLPI instance sometimes reply via a task instead of + * a followup, so both have to be checked. For every ticket that has at + * least one of either, fetch its last followup AND its last task, then + * keep whichever is more recent. Run all those lookups concurrently + * (Http::pool) so an 8-ticket dashboard panel costs one round trip, not + * up to sixteen sequential ones. + * + * @param array> $rows + * @return array ticket id => author user id + */ + protected function fetchLastInteractionAuthors(string $sessionToken, array $rows): array + { + $ticketIds = collect($rows) + ->filter(fn (array $row) => (int) ($row['27'] ?? 0) > 0 || (int) ($row['28'] ?? 0) > 0) + ->pluck('2') + ->all(); + + if (empty($ticketIds)) { + return []; + } + + $subItemParams = ['sort' => 'date', 'order' => 'DESC', 'range' => '0-0']; + + $responses = Http::pool(fn ($pool) => collect($ticketIds)->flatMap(fn ($ticketId) => [ + $pool->as("followup-{$ticketId}")->withHeaders($this->headers($sessionToken)) + ->get("{$this->baseUrl}/Ticket/{$ticketId}/ITILFollowup", $subItemParams), + $pool->as("task-{$ticketId}")->withHeaders($this->headers($sessionToken)) + ->get("{$this->baseUrl}/Ticket/{$ticketId}/TicketTask", $subItemParams), + ])->all()); + + $latestPerTicket = []; + + foreach (['followup', 'task'] as $type) { + foreach ($ticketIds as $ticketId) { + $response = $responses["{$type}-{$ticketId}"] ?? null; + + if (! $response instanceof \Illuminate\Http\Client\Response || $response->failed()) { + continue; + } + + $entry = $response->json('0'); + + if (! isset($entry['users_id'], $entry['date'])) { + continue; + } + + $ticketId = (string) $ticketId; + $existing = $latestPerTicket[$ticketId] ?? null; + + if ($existing === null || $entry['date'] > $existing['date']) { + $latestPerTicket[$ticketId] = $entry; + } + } + } + + return collect($latestPerTicket)->map(fn ($entry) => (string) $entry['users_id'])->all(); + } + + /** + * Ticket search results only carry raw GLPI user IDs for the technician + * fields (unlike single-value dropdowns such as category, these aren't + * auto-resolved to a display name) — batch-resolve them by name in one + * extra call within the same session. + * + * @param Collection $userIds + * @return array id => "Firstname Lastname" + */ + protected function resolveUserNames(string $sessionToken, Collection $userIds): array + { + if ($userIds->isEmpty()) { + return []; + } + + $response = Http::withHeaders($this->headers($sessionToken)) + ->get("{$this->baseUrl}/search/User", [ + 'criteria' => $this->buildOrCriteria(2, $userIds->all()), + 'forcedisplay' => [2, 9, 34], + 'range' => '0-'.($userIds->count() - 1), + ]); + + if ($response->failed()) { + return []; + } + + return collect($response->json('data') ?? []) + ->mapWithKeys(function (array $row) { + $name = trim(($row['9'] ?? '').' '.($row['34'] ?? '')); + + return [(string) $row['2'] => $name !== '' ? $name : null]; + }) + ->filter() + ->all(); + } + + /** + * Builds a GLPI search "criteria" array matching any of the given values + * against a single field, e.g. field=2 equals 5 OR field=2 equals 9. + */ + protected function buildOrCriteria(int $field, array $values): array + { + $criteria = []; + + foreach (array_values($values) as $index => $value) { + $criteria[] = [ + ...($index > 0 ? ['link' => 'OR'] : []), + 'field' => $field, + 'searchtype' => 'equals', + 'value' => $value, + ]; + } + + return $criteria; + } + /** * GLPI's ticket description comes back as HTML, itself HTML-entity-encoded * (e.g. "<p>" for "

"). Strip markup and trim to a short diff --git a/resources/views/components/organizations/⚡dashboard.blade.php b/resources/views/components/organizations/⚡dashboard.blade.php index 253624d..da6dc4a 100644 --- a/resources/views/components/organizations/⚡dashboard.blade.php +++ b/resources/views/components/organizations/⚡dashboard.blade.php @@ -373,10 +373,13 @@ new class extends Component @else

    @foreach ($glpiTickets as $ticket) -
  • +
  • + @if ($ticket['awaiting_technician']) + + @endif #{{ $ticket['id'] }} — {{ $ticket['title'] }}
    @@ -403,11 +406,22 @@ new class extends Component

    {{ $ticket['excerpt'] }}

    @endif -

    - {{ \Illuminate\Support\Carbon::parse($ticket['date_mod'])->diffForHumans() }} +

    + {{ \Illuminate\Support\Carbon::parse($ticket['date_mod'])->diffForHumans() }} @if ($ticket['category']) - · {{ $ticket['category'] }} + + {{ $ticket['category'] }} @endif + @if ($ticket['technician']) + + + {{ $ticket['technician'] }} + + @endif + + + {{ $ticket['interactions_count'] }} +

  • @endforeach