diff --git a/app/Http/Controllers/AttachmentController.php b/app/Http/Controllers/AttachmentController.php index 11072fe..2df5e92 100644 --- a/app/Http/Controllers/AttachmentController.php +++ b/app/Http/Controllers/AttachmentController.php @@ -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); + } + } } diff --git a/app/Http/Controllers/DevisController.php b/app/Http/Controllers/DevisController.php index 336cc79..50889a0 100644 --- a/app/Http/Controllers/DevisController.php +++ b/app/Http/Controllers/DevisController.php @@ -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, ], ]); } diff --git a/app/Http/Controllers/UserController.php b/app/Http/Controllers/UserController.php new file mode 100644 index 0000000..58dc184 --- /dev/null +++ b/app/Http/Controllers/UserController.php @@ -0,0 +1,137 @@ +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.'); + } +} diff --git a/app/Http/Middleware/HandleInertiaRequests.php b/app/Http/Middleware/HandleInertiaRequests.php index f5c7666..eb18a57 100644 --- a/app/Http/Middleware/HandleInertiaRequests.php +++ b/app/Http/Middleware/HandleInertiaRequests.php @@ -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; + } + }), ]; } } diff --git a/app/Services/GeminiService.php b/app/Services/GeminiService.php new file mode 100644 index 0000000..dc2ad02 --- /dev/null +++ b/app/Services/GeminiService.php @@ -0,0 +1,137 @@ +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; + } +} diff --git a/app/Services/OllamaService.php b/app/Services/OllamaService.php new file mode 100644 index 0000000..31632f3 --- /dev/null +++ b/app/Services/OllamaService.php @@ -0,0 +1,145 @@ +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; + } +} diff --git a/config/services.php b/config/services.php index 6a90eb8..3b1dc56 100644 --- a/config/services.php +++ b/config/services.php @@ -35,4 +35,15 @@ return [ ], ], + 'gemini' => [ + 'key' => env('GEMINI_API_KEY'), + 'model' => env('GEMINI_MODEL', 'gemini-1.5-flash'), + ], + + 'ollama' => [ + 'url' => env('OLLAMA_URL', 'http://localhost:11434'), + 'model' => env('OLLAMA_MODEL', 'glm-ocr'), + ], + ]; + diff --git a/package-lock.json b/package-lock.json index 55a7e97..312f8da 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,5 +1,5 @@ { - "name": "OrderCheck", + "name": "SuivisCommande", "lockfileVersion": 3, "requires": true, "packages": { diff --git a/resources/js/Layouts/AuthenticatedLayout.vue b/resources/js/Layouts/AuthenticatedLayout.vue index a4bdc87..c1f9bd7 100644 --- a/resources/js/Layouts/AuthenticatedLayout.vue +++ b/resources/js/Layouts/AuthenticatedLayout.vue @@ -60,6 +60,13 @@ const showingNavigationDropdown = ref(false); > Gestion des Devis + + Utilisateurs + @@ -191,6 +198,13 @@ const showingNavigationDropdown = ref(false); > Gestion des Devis + + Utilisateurs + diff --git a/resources/js/Pages/Commandes/Form.vue b/resources/js/Pages/Commandes/Form.vue index 453113f..c019181 100644 --- a/resources/js/Pages/Commandes/Form.vue +++ b/resources/js/Pages/Commandes/Form.vue @@ -1,11 +1,15 @@ @@ -378,6 +386,9 @@ const getTimelineColor = (status) => { {{ new Intl.NumberFormat('fr-FR', { style: 'currency', currency: 'EUR' }).format(sd.total_ttc) }} + + {{ getPercentage(sd.total_ttc) }} + Voir → diff --git a/resources/js/Pages/Devis/Create.vue b/resources/js/Pages/Devis/Create.vue index a9d5957..dddd7cf 100644 --- a/resources/js/Pages/Devis/Create.vue +++ b/resources/js/Pages/Devis/Create.vue @@ -172,7 +172,7 @@ const formatCurrency = (value) => { P.U. HT (€) - + diff --git a/resources/js/Pages/Devis/CreateFromCommande.vue b/resources/js/Pages/Devis/CreateFromCommande.vue index f98aeec..e79eb8d 100644 --- a/resources/js/Pages/Devis/CreateFromCommande.vue +++ b/resources/js/Pages/Devis/CreateFromCommande.vue @@ -1,12 +1,106 @@ + + + + + + + + + {{ isEdit ? `Modifier l'Utilisateur` : 'Créer un Utilisateur' }} + + + Retour + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Admin Réseau (accès standard) + Chef de Service (accès administrateur) + + + + + + + + Annuler + + + + + + + + {{ isEdit ? 'Enregistrer les modifications' : 'Créer l\'utilisateur' }} + + + + + + + + diff --git a/resources/js/Pages/Users/Index.vue b/resources/js/Pages/Users/Index.vue new file mode 100644 index 0000000..41014d1 --- /dev/null +++ b/resources/js/Pages/Users/Index.vue @@ -0,0 +1,118 @@ + + + + + + + + + + Gestion des Utilisateurs + + + + Nouvel Utilisateur + + + + + + + + + {{ $page.props.flash.success }} + + + {{ $page.props.errors.error }} + + + + + + + + + Nom + Email + Rôle + Inscrit le + Actions + + + + + + {{ user.name }} + + Moi + + + + {{ user.email }} + + + + Chef de Service + + + Admin Réseau + + + + {{ formatDate(user.created_at) }} + + + + Modifier + + + Supprimer + + + + + + + + + + + diff --git a/routes/web.php b/routes/web.php index 055cc79..26d6902 100644 --- a/routes/web.php +++ b/routes/web.php @@ -10,6 +10,7 @@ use App\Http\Controllers\AttachmentController; use App\Http\Controllers\HardwareController; use App\Http\Controllers\BesoinInformatiqueController; use App\Http\Controllers\DevisController; +use App\Http\Controllers\UserController; Route::get('/', function () { return redirect()->route('login'); @@ -71,6 +72,8 @@ Route::middleware('auth')->group(function () { // Pièces jointes Route::get('/attachments/{attachment}', [AttachmentController::class, 'show'])->name('attachments.show'); + Route::post('/attachments/{attachment}/ocr', [AttachmentController::class, 'ocr'])->name('attachments.ocr'); + Route::post('/attachments/ocr-upload', [AttachmentController::class, 'ocrUpload'])->name('attachments.ocr-upload'); // Gestion de matériel d'infrastructure Route::resource('materiels', HardwareController::class); @@ -89,6 +92,9 @@ Route::middleware('auth')->group(function () { Route::resource('devis', DevisController::class)->parameters([ 'devis' => 'devi', ]); + + // Gestion des utilisateurs + Route::resource('users', UserController::class); }); require __DIR__.'/auth.php';