'Nouveau', 2 => 'En cours (assigné)', 3 => 'En cours (planifié)', 4 => 'En attente', 5 => 'Résolu', 6 => 'Clos', ]; public const STATUS_CLOSED = 6; public const PRIORITIES = [ 1 => 'Très basse', 2 => 'Basse', 3 => 'Moyenne', 4 => 'Haute', 5 => 'Très haute', 6 => 'Majeure', ]; protected string $baseUrl; protected string $appToken; protected string $userToken; public function __construct() { $this->baseUrl = rtrim((string) config('glpi.api_url'), '/'); $this->appToken = (string) config('glpi.app_token'); $this->userToken = (string) config('glpi.user_token'); } public function isConfigured(): bool { return $this->baseUrl !== '' && $this->appToken !== '' && $this->userToken !== ''; } /** * The GLPI front-end base URL (for building links to tickets), derived * from the REST API URL by dropping the trailing "apirest.php". */ public function frontUrl(): string { return preg_replace('#/apirest\.php$#', '', $this->baseUrl); } public function ticketUrl(int $ticketId): string { return $this->frontUrl()."/front/ticket.form.php?id={$ticketId}"; } /** * Recent tickets for a GLPI entity, most recently modified first. * * @return array{tickets: array>, total: int, error: ?string} */ public function recentTicketsForEntity(int $entityId, int $limit = 8): array { if (! $this->isConfigured()) { return ['tickets' => [], 'total' => 0, 'error' => null]; } return Cache::remember( "glpi.tickets.entity.{$entityId}.{$limit}", (int) config('glpi.cache_ttl_seconds', 60), function () use ($entityId, $limit) { try { return $this->fetchRecentTickets($entityId, $limit); } catch (Throwable $e) { Log::warning('GLPI: échec de récupération des tickets', [ 'entity_id' => $entityId, 'message' => $e->getMessage(), ]); return ['tickets' => [], 'total' => 0, 'error' => "Impossible de joindre GLPI pour le moment."]; } } ); } /** * @return array{tickets: array>, total: int, error: ?string} */ protected function fetchRecentTickets(int $entityId, int $limit): array { return $this->withSession(function (string $sessionToken) use ($entityId, $limit) { $response = Http::withHeaders($this->headers($sessionToken)) ->get("{$this->baseUrl}/search/Ticket", [ 'criteria' => [ ['field' => 80, 'searchtype' => 'equals', 'value' => $entityId], // GLPI's "notequals" searchtype is silently ignored by the search // engine; excluding a value only works via a negated AND link. ['link' => 'AND NOT', 'field' => 12, 'searchtype' => 'equals', 'value' => self::STATUS_CLOSED], ], // 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), ]); if ($response->failed()) { throw new GlpiException("GLPI a répondu avec une erreur (HTTP {$response->status()})."); } $rows = $response->json('data') ?? []; $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, 'total' => (int) ($response->json('totalcount') ?? count($tickets)), 'error' => null, ]; }); } /** * 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 * plain-text preview. */ protected function excerpt(?string $html, int $length = 160): ?string { if (blank($html)) { return null; } $text = strip_tags($this->decodeText($html)); $text = trim(preg_replace('/\s+/', ' ', $text)); return $text === '' ? null : \Illuminate\Support\Str::limit($text, $length); } /** * Some GLPI fields (rich-text description, titles typed via a rich editor) * are HTML-entity-encoded twice over: once because the stored content is * itself escaped HTML (e.g. a literal "&"), and again because the * search API encodes the whole field for its JSON response. A single * decode leaves artifacts like "&" in the output, so decode twice — * harmless no-op on text that was only encoded once. */ protected function decodeText(string $value): string { return html_entity_decode(html_entity_decode($value, ENT_QUOTES), ENT_QUOTES); } /** * Opens a GLPI session, runs the callback with the session token, and * always closes the session afterwards — GLPI has no stateless auth mode * for the REST API, so every call pays this round trip. */ protected function withSession(\Closure $callback): mixed { $session = Http::withHeaders([ 'App-Token' => $this->appToken, 'Authorization' => 'user_token '.$this->userToken, ])->get("{$this->baseUrl}/initSession"); if ($session->failed()) { throw new GlpiException("Échec de connexion à GLPI (HTTP {$session->status()})."); } $sessionToken = $session->json('session_token'); try { return $callback($sessionToken); } finally { Http::withHeaders($this->headers($sessionToken))->get("{$this->baseUrl}/killSession"); } } /** * @return array */ protected function headers(string $sessionToken): array { return [ 'App-Token' => $this->appToken, 'Session-Token' => $sessionToken, ]; } }