Files
CABM-IT/app/Services/Glpi/GlpiClient.php
T
mrKamooandClaude Sonnet 5 2f576ca286 Ajoute technicien, compteur d'interactions et alerte "en attente" aux tickets GLPI
- Affiche le technicien responsable (avec repli sur le technicien assigné)
  sur chaque ticket du tableau de bord.
- Compte les interactions technicien/demandeur en combinant suivis ET
  tâches GLPI — les techniciens répondent parfois via une tâche plutôt
  qu'un suivi, un premier passage qui ne comptait que les suivis sous-
  estimait donc les échanges et déclenchait de fausses alertes.
- Signale visuellement (bordure + fond ambre, pictogramme ⚠️) les tickets
  dont la dernière interaction vient du demandeur, en comparant la date du
  dernier suivi et de la dernière tâche pour déterminer qui a parlé en
  dernier.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-19 17:58:11 +02:00

362 lines
14 KiB
PHP

<?php
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;
use Throwable;
class GlpiClient
{
public const STATUSES = [
1 => '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<int, array<string, mixed>>, 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<int, array<string, mixed>>, 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<int, array<string, mixed>> $rows
* @return array<string, string> 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<int, int|string> $userIds
* @return array<string, string> 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. "&#60;p&#62;" for "<p>"). 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 "&amp;"), and again because the
* search API encodes the whole field for its JSON response. A single
* decode leaves artifacts like "&amp;" 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<string, string>
*/
protected function headers(string $sessionToken): array
{
return [
'App-Token' => $this->appToken,
'Session-Token' => $sessionToken,
];
}
}