feat: implement user management system and integrate AI services with Ollama and Gemini

This commit is contained in:
jeremy bayse
2026-06-21 00:12:37 +02:00
parent 648749a69a
commit b85c2ba5a1
17 changed files with 1074 additions and 7 deletions
@@ -3,8 +3,12 @@
namespace App\Http\Controllers;
use App\Models\Attachment;
use App\Services\GeminiService;
use App\Services\OllamaService;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Facades\Gate;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class AttachmentController extends Controller
{
@@ -30,4 +34,61 @@ class AttachmentController extends Controller
'Content-Disposition' => 'inline; filename="' . basename($attachment->file_name) . '"'
]);
}
/**
* Effectue une OCRisation de la pièce jointe à l'aide de l'API Gemini.
*/
public function ocr(Request $request, Attachment $attachment, GeminiService $geminiService, OllamaService $ollamaService): JsonResponse
{
// On vérifie si l'utilisateur a le droit de voir la commande liée à cette pièce jointe
$order = $attachment->order;
Gate::authorize('view', $order);
try {
$mode = $request->input('mode', 'gemini');
if ($mode === 'ollama') {
$data = $ollamaService->analyzeQuote($attachment);
} else {
$data = $geminiService->analyzeQuote($attachment);
}
return response()->json([
'success' => true,
'data' => $data
]);
} catch (\Exception $e) {
return response()->json([
'success' => false,
'message' => $e->getMessage()
], 500);
}
}
/**
* Effectue une OCRisation directe sur un fichier téléchargé temporairement.
*/
public function ocrUpload(Request $request, GeminiService $geminiService, OllamaService $ollamaService): JsonResponse
{
$request->validate([
'file' => 'required|file|mimes:pdf,png,jpg,jpeg,webp|max:10240',
'mode' => 'nullable|string'
]);
try {
$mode = $request->input('mode', 'gemini');
if ($mode === 'ollama') {
$data = $ollamaService->analyzeUploadedFile($request->file('file'));
} else {
$data = $geminiService->analyzeUploadedFile($request->file('file'));
}
return response()->json([
'success' => true,
'data' => $data
]);
} catch (\Exception $e) {
return response()->json([
'success' => false,
'message' => $e->getMessage()
], 500);
}
}
}
+7
View File
@@ -78,6 +78,12 @@ class DevisController extends Controller
public function createFromCommande(\App\Models\Order $commande)
{
$commande->load('attachments');
$quoteAttachments = $commande->attachments->where('file_type', 'quote')->map(fn($a) => [
'id' => $a->id,
'file_name' => $a->file_name,
])->values()->all();
return Inertia::render('Devis/CreateFromCommande', [
'commande' => [
'id' => $commande->id,
@@ -85,6 +91,7 @@ class DevisController extends Controller
'label' => $commande->label,
'supplier' => $commande->supplier,
'quote_number' => $commande->quote_number,
'quote_attachments' => $quoteAttachments,
],
]);
}
+137
View File
@@ -0,0 +1,137 @@
<?php
namespace App\Http\Controllers;
use App\Models\User;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash;
use Illuminate\Validation\Rule;
use Inertia\Inertia;
class UserController extends Controller
{
/**
* Vérifie les droits d'administration de l'utilisateur.
*/
protected function authorizeAdmin(Request $request)
{
if ($request->user()->role !== 'chef_service') {
abort(403, 'Action non autorisée. Réservé aux chefs de service.');
}
}
/**
* Liste des utilisateurs.
*/
public function index(Request $request)
{
$this->authorizeAdmin($request);
$users = User::latest()->get();
return Inertia::render('Users/Index', [
'users' => $users,
]);
}
/**
* Formulaire de création.
*/
public function create(Request $request)
{
$this->authorizeAdmin($request);
return Inertia::render('Users/Form', [
'isEdit' => false,
'user' => null,
]);
}
/**
* Enregistrer un nouvel utilisateur.
*/
public function store(Request $request)
{
$this->authorizeAdmin($request);
$validated = $request->validate([
'name' => 'required|string|max:255',
'email' => 'required|string|email|max:255|unique:users',
'password' => 'required|string|min:8',
'role' => 'required|string|in:chef_service,admin_reseau',
]);
User::create([
'name' => $validated['name'],
'email' => $validated['email'],
'password' => Hash::make($validated['password']),
'role' => $validated['role'],
]);
return redirect()->route('users.index')->with('success', 'Utilisateur créé avec succès.');
}
/**
* Formulaire d'édition.
*/
public function edit(Request $request, User $user)
{
$this->authorizeAdmin($request);
return Inertia::render('Users/Form', [
'isEdit' => true,
'user' => $user,
]);
}
/**
* Mettre à jour un utilisateur.
*/
public function update(Request $request, User $user)
{
$this->authorizeAdmin($request);
$validated = $request->validate([
'name' => 'required|string|max:255',
'email' => [
'required',
'string',
'email',
'max:255',
Rule::unique('users')->ignore($user->id),
],
'password' => 'nullable|string|min:8',
'role' => 'required|string|in:chef_service,admin_reseau',
]);
$data = [
'name' => $validated['name'],
'email' => $validated['email'],
'role' => $validated['role'],
];
if (!empty($validated['password'])) {
$data['password'] = Hash::make($validated['password']);
}
$user->update($data);
return redirect()->route('users.index')->with('success', 'Utilisateur mis à jour avec succès.');
}
/**
* Supprimer un utilisateur.
*/
public function destroy(Request $request, User $user)
{
$this->authorizeAdmin($request);
if ($request->user()->id === $user->id) {
return redirect()->back()->withErrors(['error' => 'Vous ne pouvez pas vous supprimer vous-même !']);
}
$user->delete();
return redirect()->route('users.index')->with('success', 'Utilisateur supprimé avec succès.');
}
}
@@ -39,6 +39,17 @@ class HandleInertiaRequests extends Middleware
'error' => fn () => $request->session()->get('error'),
],
'appName' => config('app.name'),
'ollamaAvailable' => cache()->remember('ollama_available', 15, function () {
try {
$url = rtrim(config('services.ollama.url', 'http://127.0.0.1:11434'), '/');
// Remplacement de localhost par 127.0.0.1 pour contourner les lenteurs de résolution DNS sur Windows
$url = str_replace('localhost', '127.0.0.1', $url);
$response = \Illuminate\Support\Facades\Http::timeout(2.0)->get($url);
return $response->successful();
} catch (\Exception $e) {
return false;
}
}),
];
}
}
+137
View File
@@ -0,0 +1,137 @@
<?php
namespace App\Services;
use App\Models\Attachment;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Facades\Log;
class GeminiService
{
protected ?string $apiKey;
protected string $model;
public function __construct()
{
$this->apiKey = config('services.gemini.key');
$this->model = config('services.gemini.model', 'gemini-1.5-flash');
}
/**
* Analyse un document joint via l'API Gemini et extrait les détails structurés du devis.
*/
public function analyzeQuote(Attachment $attachment): array
{
$disk = Storage::disk('public');
if (!$disk->exists($attachment->file_path)) {
throw new \Exception("Le fichier joint n'existe pas sur le disque.");
}
$path = $disk->path($attachment->file_path);
$fileContent = file_get_contents($path);
$mimeType = $disk->mimeType($attachment->file_path);
return $this->analyzeContent($fileContent, $mimeType, $attachment->file_name);
}
/**
* Analyse directement un fichier téléchargé temporairement via l'API Gemini.
*/
public function analyzeUploadedFile(\Illuminate\Http\UploadedFile $file): array
{
$fileContent = file_get_contents($file->getRealPath());
$mimeType = $file->getMimeType();
return $this->analyzeContent($fileContent, $mimeType, $file->getClientOriginalName());
}
/**
* Exécute l'appel à l'API Gemini pour extraire les données du devis.
*/
protected function analyzeContent(string $fileContent, string $mimeType, string $fileName): array
{
if (empty($this->apiKey)) {
throw new \Exception("La clé API Gemini n'est pas configurée dans l'environnement.");
}
// Encodage Base64 pour l'API Gemini
$base64Data = base64_encode($fileContent);
// Prompt strict demandant un format JSON conforme aux attentes du formulaire
$prompt = "Tu es un assistant administratif spécialisé dans l'analyse de devis informatiques et de prestations.
Analyse le document joint (devis) et extrais de façon très précise les informations suivantes au format JSON uniquement.
Structure JSON attendue :
{
\"title\": \"Un titre descriptif pour le devis (ex: Achat matériel informatique ou Prestation d'installation)\",
\"date_devis\": \"La date d'émission du devis au format YYYY-MM-DD (si non trouvée, renvoie null)\",
\"supplier\": \"Le nom de l'entreprise émettrice / fournisseur\",
\"quote_number\": \"Le numéro de devis du fournisseur\",
\"tva_rate\": 20.0, // le taux de TVA standard constaté dans le document (valeur numérique, ex: 20 ou 5.5)
\"total_ht\": 1250.00, // Le montant total HT du devis (nombre décimal)
\"items\": [
{
\"type\": \"Matériel|Licence|Prestation|Infrastructure|Autre\", // Choisis la catégorie la plus adaptée parmi ces 5 valeurs exactes
\"designation\": \"La description de l'article ou de la prestation\",
\"quantite\": 1, // La quantité (entier ou décimal)
\"prix_unitaire\": 120.50 // Le prix unitaire HT (nombre décimal)
}
]
}
Règles importantes :
1. Extrais TOUTES les lignes d'articles réelles présentes dans le devis. Ne résume pas ou ne groupe pas les lignes si elles sont détaillées.
2. Pour chaque article, détermine la catégorie la plus adaptée ('Matériel', 'Licence', 'Prestation', 'Infrastructure', 'Autre').
3. Ne réponds QUE par le JSON brut, valide, sans aucun texte explicatif avant ou après, et sans bloc de code markdown.
4. Assure-toi que les montants et quantités sont des nombres.";
$url = "https://generativelanguage.googleapis.com/v1beta/models/{$this->model}:generateContent?key={$this->apiKey}";
Log::info("Début de l'analyse OCR Gemini pour le fichier : {$fileName}");
$response = Http::withHeaders([
'Content-Type' => 'application/json',
])->post($url, [
'contents' => [
[
'parts' => [
['text' => $prompt],
[
'inlineData' => [
'mimeType' => $mimeType,
'data' => $base64Data
]
]
]
]
],
'generationConfig' => [
'responseMimeType' => 'application/json',
]
]);
if ($response->failed()) {
Log::error("Échec de l'appel Gemini API : " . $response->body());
throw new \Exception("Erreur de communication avec l'API Gemini : " . $response->status());
}
$data = $response->json();
$textResult = $data['candidates'][0]['content']['parts'][0]['text'] ?? null;
if (!$textResult) {
Log::error("Gemini n'a renvoyé aucun contenu exploitable.");
throw new \Exception("L'analyse du devis n'a retourné aucun résultat.");
}
$decoded = json_decode($textResult, true);
if (json_last_error() !== JSON_ERROR_NONE) {
Log::error("Impossible de parser le JSON retourné par Gemini : " . $textResult);
throw new \Exception("Le format des données retournées par l'OCR est invalide.");
}
Log::info("OCR Gemini réussi pour le fichier : {$fileName}");
return $decoded;
}
}
+145
View File
@@ -0,0 +1,145 @@
<?php
namespace App\Services;
use App\Models\Attachment;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Facades\Log;
class OllamaService
{
protected string $url;
protected string $model;
public function __construct()
{
$this->url = rtrim(config('services.ollama.url', 'http://localhost:11434'), '/');
$this->model = config('services.ollama.model', 'glm-ocr');
}
/**
* Analyse un document joint via l'API Ollama et extrait les détails structurés du devis.
*/
public function analyzeQuote(Attachment $attachment): array
{
$disk = Storage::disk('public');
if (!$disk->exists($attachment->file_path)) {
throw new \Exception("Le fichier joint n'existe pas sur le disque.");
}
$path = $disk->path($attachment->file_path);
$fileContent = file_get_contents($path);
$mimeType = $disk->mimeType($attachment->file_path);
return $this->analyzeContent($fileContent, $mimeType, $attachment->file_name);
}
/**
* Analyse directement un fichier téléchargé temporairement via l'API Ollama.
*/
public function analyzeUploadedFile(\Illuminate\Http\UploadedFile $file): array
{
$fileContent = file_get_contents($file->getRealPath());
$mimeType = $file->getMimeType();
return $this->analyzeContent($fileContent, $mimeType, $file->getClientOriginalName());
}
/**
* Exécute l'appel à l'API Ollama pour extraire les données du devis.
*/
protected function analyzeContent(string $fileContent, string $mimeType, string $fileName): array
{
if ($mimeType === 'application/pdf') {
throw new \Exception("Le modèle local (Ollama/GLM-OCR) ne prend en charge que les images (PNG, JPG, JPEG, WEBP). Pour analyser un fichier PDF, veuillez utiliser Gemini ou convertir votre document PDF en image.");
}
// Encodage Base64
$base64Data = base64_encode($fileContent);
// Prompt strict demandant un format JSON conforme aux attentes du formulaire sans inclure de placeholders dans la structure JSON
$prompt = "Tu es un assistant administratif spécialisé dans l'analyse de devis.
Analyse le document joint pour en extraire très précisément les informations requises et retourne-les au format JSON uniquement.
Instructions d'extraction :
- title : Donne un titre descriptif et court basé sur l'objet du devis (ex: 'Achat matériel' ou 'Prestation installation').
- date_devis : Extrais la date d'émission au format YYYY-MM-DD (renvoie null si absente).
- supplier : Extrais le nom de l'entreprise émettrice / fournisseur.
- quote_number : Extrais le numéro du devis.
- tva_rate : Détermine le taux de TVA standard constaté (nombre décimal, ex: 20.0).
- total_ht : Extrais le montant total HT du devis (nombre décimal).
- items : Liste de TOUTES les lignes d'articles réelles détaillées. Pour chaque article :
* type : Choisis une valeur parmi ces 5 valeurs exactes uniquement : 'Matériel', 'Licence', 'Prestation', 'Infrastructure', 'Autre'.
* designation : La description / libellé de la ligne d'article.
* quantite : La quantité (nombre).
* prix_unitaire : Le prix unitaire HT (nombre).
Retourne UNIQUEMENT le JSON brut suivant cette structure exacte, sans commentaires, sans texte explicatif, et sans bloc markdown :
{
\"title\": \"\",
\"date_devis\": \"\",
\"supplier\": \"\",
\"quote_number\": \"\",
\"tva_rate\": 20.0,
\"total_ht\": 0.0,
\"items\": [
{
\"type\": \"\",
\"designation\": \"\",
\"quantite\": 1,
\"prix_unitaire\": 0.0
}
]
}";
$url = "{$this->url}/api/generate";
Log::info("Début de l'analyse OCR Ollama (GLM-OCR) pour le fichier : {$fileName}");
// Appel API Ollama
$response = Http::withHeaders([
'Content-Type' => 'application/json',
])->timeout(120)->post($url, [
'model' => $this->model,
'prompt' => $prompt,
'images' => [$base64Data],
'stream' => false,
'format' => 'json',
'options' => [
'temperature' => 0.0,
]
]);
if ($response->failed()) {
Log::error("Échec de l'appel Ollama API : " . $response->body());
throw new \Exception("Erreur de communication avec Ollama : " . $response->status());
}
$data = $response->json();
$textResult = $data['response'] ?? null;
if (!$textResult) {
Log::error("Ollama n'a renvoyé aucun contenu exploitable.");
throw new \Exception("L'analyse du devis n'a retourné aucun résultat.");
}
// Nettoyer si du markdown entoure le JSON (parfois Ollama insiste malgré format: json)
$textResult = trim($textResult);
if (str_starts_with($textResult, '```')) {
$textResult = preg_replace('/^```(?:json)?\s*/i', '', $textResult);
$textResult = preg_replace('/```$/', '', $textResult);
$textResult = trim($textResult);
}
$decoded = json_decode($textResult, true);
if (json_last_error() !== JSON_ERROR_NONE) {
Log::error("Impossible de parser le JSON retourné par Ollama : " . $textResult);
throw new \Exception("Le format des données retournées par l'OCR Ollama est invalide.");
}
Log::info("OCR Ollama réussi pour le fichier : {$fileName}");
return $decoded;
}
}