Ajoute géolocalisation des sites, duplication d'actifs et intégration GLPI
- Coordonnées GPS sur les sites (formulaire + carte Leaflet en direct sur la fiche et sur la liste des sites d'une organisation). - Duplication d'un actif depuis la liste. - Intégration GLPI en lecture seule : panneau "Tickets" sur le tableau de bord de l'organisation (tickets non clos, priorité, catégorie, extrait), organisations liées à leur entité GLPI via glpi_entity_id. - Corrige la mise en cache des permissions Spatie lors du seed (silencée par WithoutModelEvents) et rend le seeder admin idempotent. - Ajoute busy_timeout/WAL à la config SQLite pour éviter les erreurs de verrou sous écritures concurrentes. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
2d12d3c2b2
commit
01233928cc
@@ -8,6 +8,7 @@ use Illuminate\Database\Eloquent\Model;
|
||||
#[Fillable([
|
||||
'organization_id', 'name', 'address_line_1', 'address_line_2',
|
||||
'city', 'region', 'postal_code', 'country', 'phone', 'is_primary',
|
||||
'latitude', 'longitude',
|
||||
])]
|
||||
class Location extends Model
|
||||
{
|
||||
@@ -15,6 +16,8 @@ class Location extends Model
|
||||
{
|
||||
return [
|
||||
'is_primary' => 'boolean',
|
||||
'latitude' => 'float',
|
||||
'longitude' => 'float',
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
#[Fillable(['name', 'slug', 'org_type', 'status', 'description', 'allowed_ip_ranges', 'logo_path'])]
|
||||
#[Fillable(['name', 'slug', 'org_type', 'status', 'description', 'allowed_ip_ranges', 'logo_path', 'glpi_entity_id'])]
|
||||
class Organization extends Model
|
||||
{
|
||||
use SoftDeletes;
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Glpi;
|
||||
|
||||
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],
|
||||
],
|
||||
'forcedisplay' => [2, 1, 12, 3, 7, 21, 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') ?? [];
|
||||
|
||||
$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();
|
||||
|
||||
return [
|
||||
'tickets' => $tickets,
|
||||
'total' => (int) ($response->json('totalcount') ?? count($tickets)),
|
||||
'error' => null,
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* GLPI's ticket description comes back as HTML, itself HTML-entity-encoded
|
||||
* (e.g. "<p>" 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 "&"), 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<string, string>
|
||||
*/
|
||||
protected function headers(string $sessionToken): array
|
||||
{
|
||||
return [
|
||||
'App-Token' => $this->appToken,
|
||||
'Session-Token' => $sessionToken,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Glpi;
|
||||
|
||||
use RuntimeException;
|
||||
|
||||
class GlpiException extends RuntimeException
|
||||
{
|
||||
}
|
||||
Reference in New Issue
Block a user