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>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
2bf169ed88
commit
2f576ca286
@@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
namespace App\Services\Glpi;
|
namespace App\Services\Glpi;
|
||||||
|
|
||||||
|
use Illuminate\Support\Arr;
|
||||||
|
use Illuminate\Support\Collection;
|
||||||
use Illuminate\Support\Facades\Cache;
|
use Illuminate\Support\Facades\Cache;
|
||||||
use Illuminate\Support\Facades\Http;
|
use Illuminate\Support\Facades\Http;
|
||||||
use Illuminate\Support\Facades\Log;
|
use Illuminate\Support\Facades\Log;
|
||||||
@@ -104,7 +106,11 @@ class GlpiClient
|
|||||||
// engine; excluding a value only works via a negated AND link.
|
// engine; excluding a value only works via a negated AND link.
|
||||||
['link' => 'AND NOT', 'field' => 12, 'searchtype' => 'equals', 'value' => self::STATUS_CLOSED],
|
['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,
|
'sort' => 19,
|
||||||
'order' => 'DESC',
|
'order' => 'DESC',
|
||||||
'range' => '0-'.($limit - 1),
|
'range' => '0-'.($limit - 1),
|
||||||
@@ -116,18 +122,51 @@ class GlpiClient
|
|||||||
|
|
||||||
$rows = $response->json('data') ?? [];
|
$rows = $response->json('data') ?? [];
|
||||||
|
|
||||||
$tickets = collect($rows)->map(fn (array $row) => [
|
$technicianIds = collect($rows)
|
||||||
'id' => $row['2'],
|
->flatMap(fn (array $row) => [
|
||||||
'title' => $this->decodeText((string) $row['1']),
|
...Arr::wrap($row['95'] ?? null),
|
||||||
'status' => (int) $row['12'],
|
...Arr::wrap($row['5'] ?? null),
|
||||||
'status_label' => self::STATUSES[(int) $row['12']] ?? 'Inconnu',
|
])
|
||||||
'category' => filled($row['7'] ?? null) ? $this->decodeText((string) $row['7']) : null,
|
->filter()
|
||||||
'excerpt' => $this->excerpt($row['21'] ?? null),
|
->unique()
|
||||||
'priority' => (int) $row['3'],
|
->values();
|
||||||
'priority_label' => self::PRIORITIES[(int) $row['3']] ?? 'Inconnue',
|
|
||||||
'date_mod' => $row['19'],
|
$technicianNames = $this->resolveUserNames($sessionToken, $technicianIds);
|
||||||
'url' => $this->ticketUrl((int) $row['2']),
|
$lastInteractionAuthors = $this->fetchLastInteractionAuthors($sessionToken, $rows);
|
||||||
])->all();
|
|
||||||
|
$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 [
|
return [
|
||||||
'tickets' => $tickets,
|
'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<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
|
* GLPI's ticket description comes back as HTML, itself HTML-entity-encoded
|
||||||
* (e.g. "<p>" for "<p>"). Strip markup and trim to a short
|
* (e.g. "<p>" for "<p>"). Strip markup and trim to a short
|
||||||
|
|||||||
@@ -373,10 +373,13 @@ new class extends Component
|
|||||||
@else
|
@else
|
||||||
<ul class="divide-y divide-slate-100">
|
<ul class="divide-y divide-slate-100">
|
||||||
@foreach ($glpiTickets as $ticket)
|
@foreach ($glpiTickets as $ticket)
|
||||||
<li class="py-2.5 text-sm">
|
<li class="py-2.5 text-sm {{ $ticket['awaiting_technician'] ? '-mx-4 border-l-4 border-amber-400 bg-amber-50 px-4' : '' }}">
|
||||||
<div class="flex items-start justify-between gap-4">
|
<div class="flex items-start justify-between gap-4">
|
||||||
<a href="{{ $ticket['url'] }}" target="_blank" rel="noopener"
|
<a href="{{ $ticket['url'] }}" target="_blank" rel="noopener"
|
||||||
class="min-w-0 font-medium text-slate-900 hover:underline">
|
class="min-w-0 font-medium text-slate-900 hover:underline">
|
||||||
|
@if ($ticket['awaiting_technician'])
|
||||||
|
<span aria-hidden="true" title="En attente d'une réponse du technicien">⚠️</span>
|
||||||
|
@endif
|
||||||
#{{ $ticket['id'] }} — {{ $ticket['title'] }}
|
#{{ $ticket['id'] }} — {{ $ticket['title'] }}
|
||||||
</a>
|
</a>
|
||||||
<div class="flex shrink-0 items-center gap-1.5">
|
<div class="flex shrink-0 items-center gap-1.5">
|
||||||
@@ -403,11 +406,22 @@ new class extends Component
|
|||||||
<p class="mt-1 text-xs text-slate-600">{{ $ticket['excerpt'] }}</p>
|
<p class="mt-1 text-xs text-slate-600">{{ $ticket['excerpt'] }}</p>
|
||||||
@endif
|
@endif
|
||||||
|
|
||||||
<p class="mt-1 text-xs text-slate-400">
|
<p class="mt-1 flex flex-wrap items-center gap-x-1.5 gap-y-1 text-xs text-slate-400">
|
||||||
{{ \Illuminate\Support\Carbon::parse($ticket['date_mod'])->diffForHumans() }}
|
<span>{{ \Illuminate\Support\Carbon::parse($ticket['date_mod'])->diffForHumans() }}</span>
|
||||||
@if ($ticket['category'])
|
@if ($ticket['category'])
|
||||||
· {{ $ticket['category'] }}
|
<span aria-hidden="true">·</span>
|
||||||
|
<span>{{ $ticket['category'] }}</span>
|
||||||
@endif
|
@endif
|
||||||
|
@if ($ticket['technician'])
|
||||||
|
<span aria-hidden="true">·</span>
|
||||||
|
<span class="inline-flex items-center gap-1" title="Technicien responsable">
|
||||||
|
<span aria-hidden="true">👤</span> {{ $ticket['technician'] }}
|
||||||
|
</span>
|
||||||
|
@endif
|
||||||
|
<span aria-hidden="true">·</span>
|
||||||
|
<span class="inline-flex items-center gap-1" title="Échanges entre le technicien et le demandeur (suivis + tâches)">
|
||||||
|
<span aria-hidden="true">💬</span> {{ $ticket['interactions_count'] }}
|
||||||
|
</span>
|
||||||
</p>
|
</p>
|
||||||
</li>
|
</li>
|
||||||
@endforeach
|
@endforeach
|
||||||
|
|||||||
Reference in New Issue
Block a user