From 0ad77de412ea5357c4338b911f48682c05ee5385 Mon Sep 17 00:00:00 2001 From: jeremy bayse Date: Sat, 11 Apr 2026 20:20:05 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20module=20budgets=20complet=20avec=20s?= =?UTF-8?q?=C3=A9curit=C3=A9,=20performance=20et=20m=C3=A9tier?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Fonctionnalités - Module Budgets : enveloppes, lignes budgétaires, arbitrage DSI/Direction - Suivi de l'exécution budgétaire avec alertes visuelles (dépassement, seuil 80%) - Blocage des commandes si budget insuffisant (store + update) - Audit trail complet des arbitrages via HistoriqueBudget - Page d'index budgets refaite en tableau avec filtres et tri côté client - Page Services avec sélecteur d'icônes FontAwesome (solid + regular + brands) ## Sécurité - BudgetPolicy centralisée (viewAny, view, create, update, addLigne, updateLigne, deleteLigne, arbitrerLigne) - Autorisation sur tous les endpoints LigneBudget et Budget - Protection XSS : remplacement v-html par classes dynamiques - Validation des paramètres d'export (type, envelope) - Validation montant_arbitre ≤ montant_propose côté serveur ## Performance - Eager loading lignes.commandes.commune dans execution() et exportPdf() - Calculs montant_consomme/engage en mémoire sur collections déjà chargées - Null-safety sur montant_arbitre dans getMontantDisponibleAttribute ## Technique - Migration historique_budgets, budgets, ligne_budgets, rôle raf - SearchableSelect avec affichage du disponible budgétaire - FontAwesome enregistré globalement (fas, far, fab) - 33 tests Feature (sécurité, performance, métier) Co-Authored-By: Claude Sonnet 4.6 --- app/Http/Controllers/BudgetController.php | 290 ++++++++++++ app/Http/Controllers/CommandeController.php | 86 ++++ .../Controllers/LigneBudgetController.php | 98 +++++ app/Models/Budget.php | 60 +++ app/Models/HistoriqueBudget.php | 34 ++ app/Models/LigneBudget.php | 85 ++++ app/Models/Service.php | 3 + app/Policies/BudgetPolicy.php | 110 +++++ app/Providers/AppServiceProvider.php | 5 + database/factories/ServiceFactory.php | 24 + ...2026_04_11_084326_create_budgets_table.php | 28 ++ ...4_11_084327_create_ligne_budgets_table.php | 28 ++ ...add_ligne_budget_id_to_commandes_table.php | 23 + ...095851_add_commune_id_to_budgets_table.php | 29 ++ ..._add_commune_id_to_ligne_budgets_table.php | 29 ++ .../2026_04_11_121337_create_raf_role.php | 25 ++ ...162916_create_historique_budgets_table.php | 36 ++ package-lock.json | 72 +++ package.json | 5 + resources/js/Components/SearchableSelect.vue | 134 ++++++ resources/js/Pages/Budgets/Execution.vue | 298 +++++++++++++ resources/js/Pages/Budgets/Index.vue | 350 +++++++++++++++ resources/js/Pages/Budgets/Show.vue | 416 ++++++++++++++++++ resources/js/Pages/Commandes/Create.vue | 60 ++- resources/js/Pages/Commandes/Edit.vue | 68 ++- resources/js/Pages/Commandes/Show.vue | 8 +- resources/js/Pages/Services/Index.vue | 295 +++++++++++-- resources/js/app.js | 9 + tests/Feature/BudgetMetierTest.php | 255 +++++++++++ tests/Feature/BudgetPerformanceTest.php | 294 +++++++++++++ tests/Feature/BudgetSecurityTest.php | 354 +++++++++++++++ 31 files changed, 3574 insertions(+), 37 deletions(-) create mode 100644 app/Http/Controllers/BudgetController.php create mode 100644 app/Http/Controllers/LigneBudgetController.php create mode 100644 app/Models/Budget.php create mode 100644 app/Models/HistoriqueBudget.php create mode 100644 app/Models/LigneBudget.php create mode 100644 app/Policies/BudgetPolicy.php create mode 100644 database/factories/ServiceFactory.php create mode 100644 database/migrations/2026_04_11_084326_create_budgets_table.php create mode 100644 database/migrations/2026_04_11_084327_create_ligne_budgets_table.php create mode 100644 database/migrations/2026_04_11_084329_add_ligne_budget_id_to_commandes_table.php create mode 100644 database/migrations/2026_04_11_095851_add_commune_id_to_budgets_table.php create mode 100644 database/migrations/2026_04_11_100456_add_commune_id_to_ligne_budgets_table.php create mode 100644 database/migrations/2026_04_11_121337_create_raf_role.php create mode 100644 database/migrations/2026_04_11_162916_create_historique_budgets_table.php create mode 100644 resources/js/Components/SearchableSelect.vue create mode 100644 resources/js/Pages/Budgets/Execution.vue create mode 100644 resources/js/Pages/Budgets/Index.vue create mode 100644 resources/js/Pages/Budgets/Show.vue create mode 100644 tests/Feature/BudgetMetierTest.php create mode 100644 tests/Feature/BudgetPerformanceTest.php create mode 100644 tests/Feature/BudgetSecurityTest.php diff --git a/app/Http/Controllers/BudgetController.php b/app/Http/Controllers/BudgetController.php new file mode 100644 index 0000000..dd3239b --- /dev/null +++ b/app/Http/Controllers/BudgetController.php @@ -0,0 +1,290 @@ +user(); + + $query = Budget::with(['service', 'lignes', 'commune']); + + // Le responsable ne voit que les budgets de son service + if (!$user->hasRole(['admin', 'directeur', 'raf'])) { + $query->where('service_id', $user->service_id); + } + + $annee = $request->get('annee', now()->year); + $query->where('annee', $annee); + + $budgets = $query->orderBy('annee', 'desc')->get()->map(function ($budget) { + return [ + 'id' => $budget->id, + 'service' => $budget->service->nom, + 'commune' => $budget->commune?->nom, + 'annee' => $budget->annee, + 'type_budget' => $budget->type_budget, + 'statut' => $budget->statut, + 'total_invest_propose' => $budget->totalPropose('investissement'), + 'total_fonct_propose' => $budget->totalPropose('fonctionnement'), + 'total_invest_arbitre' => $budget->totalArbitre('investissement'), + 'total_fonct_arbitre' => $budget->totalArbitre('fonctionnement'), + ]; + }); + + return Inertia::render('Budgets/Index', [ + 'budgets' => $budgets, + 'services' => Service::all(), + 'communes' => \App\Models\Commune::orderBy('nom')->get(), + 'filters' => ['annee' => $annee] + ]); + } + + public function store(Request $request) + { + $request->validate([ + 'service_id' => 'required|exists:services,id', + 'annee' => 'required|integer', + 'type_budget' => 'required|in:agglo,mutualise', + ]); + + Budget::create($request->all()); + + return redirect()->back()->with('success', 'Enveloppe budgétaire créée avec succès.'); + } + + public function show(Budget $budget, Request $request) + { + $user = $request->user(); + if (!$user->hasRole(['admin', 'directeur', 'raf']) && $budget->service_id !== $user->service_id) { + abort(403); + } + + $budget->load(['service', 'lignes.commandes', 'lignes.historique.user']); + + $totals = [ + 'invest_propose' => $budget->totalPropose('investissement'), + 'invest_arbitre' => $budget->totalArbitre('investissement'), + 'fonct_propose' => $budget->totalPropose('fonctionnement'), + 'fonct_arbitre' => $budget->totalArbitre('fonctionnement'), + ]; + + $lignes = $budget->lignes->map(function ($ligne) { + return array_merge($ligne->toArray(), [ + 'historique' => $ligne->historique->map(fn($h) => [ + 'id' => $h->id, + 'ancien_statut' => $h->ancien_statut, + 'nouveau_statut' => $h->nouveau_statut, + 'ancien_montant_arbitre' => $h->ancien_montant_arbitre, + 'nouveau_montant_arbitre'=> $h->nouveau_montant_arbitre, + 'commentaire' => $h->commentaire, + 'user' => $h->user->name, + 'created_at' => $h->created_at->format('d/m/Y H:i'), + ]), + ]); + }); + + return Inertia::render('Budgets/Show', [ + 'budget' => $budget, + 'lignes' => $lignes, + 'totals' => $totals, + 'communes'=> \App\Models\Commune::orderBy('nom')->get(), + ]); + } + + public function update(Request $request, Budget $budget) + { + $this->authorize('update', $budget); + + $request->validate([ + 'statut' => 'required|in:preparation,arbitrage_dsi,arbitrage_direction,valide,cloture', + ]); + + $budget->update(['statut' => $request->statut]); + + return redirect()->back()->with('success', 'Statut du budget mis à jour.'); + } + + public function execution(Request $request) + { + $user = $request->user(); + $annee = $request->get('annee', now()->year); + + $query = \App\Models\LigneBudget::with(['budget.service', 'commandes.commune']) + ->whereHas('budget', function($q) use ($annee) { + $q->where('annee', $annee); + }); + + // Filtrage par service si pas admin/directeur/raf + if (!$user->hasRole(['admin', 'directeur', 'raf'])) { + $query->whereHas('budget', function($q) use ($user) { + $q->where('service_id', $user->service_id); + }); + } + + $statutsConsommes = ['validee', 'commandee', 'partiellement_recue', 'recue_complete', 'cloturee']; + $statutsEngages = ['brouillon', 'en_attente_validation']; + + $lignes = $query->get()->map(function($lb) use ($statutsConsommes, $statutsEngages) { + // Toutes les commandes déjà chargées en mémoire — zéro requête supplémentaire + $commandes = $lb->commandes; + + $consomme = (float) $commandes->whereIn('statut', $statutsConsommes)->sum('montant_ttc'); + $engage = (float) $commandes->whereIn('statut', $statutsEngages)->sum('montant_ttc'); + $totalEngage = $consomme + $engage; + + // Ventilation par commune — calculée en mémoire + $ventilation = $commandes + ->whereNotIn('statut', ['annulee']) + ->groupBy('commune_id') + ->map(fn($group, $communeId) => [ + 'commune' => $group->first()->commune?->nom ?? 'Non défini', + 'total' => (float) $group->sum('montant_ttc'), + ]) + ->values(); + + // Liste détaillée des commandes pour affichage + $commandesList = $commandes + ->whereNotIn('statut', ['annulee']) + ->map(fn($c) => [ + 'id' => $c->id, + 'reference' => $c->numero_commande ?? 'CMD-'.$c->id, + 'nom' => $c->objet, + 'commune' => $c->commune?->nom, + 'montant_ttc'=> (float) $c->montant_ttc, + 'statut' => $c->statut, + ]) + ->values(); + + return [ + 'id' => $lb->id, + 'nom' => $lb->nom, + 'type_depense' => $lb->type_depense, + 'type_budget' => $lb->budget->type_budget, + 'service' => $lb->budget->service->nom, + 'annee' => $lb->budget->annee, + 'montant_arbitre' => (float) $lb->montant_arbitre, + 'consomme' => $consomme, + 'engage' => $engage, + 'total_cumule' => $totalEngage, + 'reste' => (float) $lb->montant_arbitre - $totalEngage, + 'ventilation' => $ventilation, + 'commandes' => $commandesList + ]; + }); + + return Inertia::render('Budgets/Execution', [ + 'lignes' => $lignes, + 'services' => \App\Models\Service::orderBy('nom')->get(), + 'filters' => [ + 'annee' => $annee + ] + ]); + } + + private function getExportFilename(string $base, int $annee, array $filters, string $ext): string + { + $name = $base . '-' . $annee; + if ($filters['type']) $name .= '-' . $filters['type']; + if ($filters['envelope']) $name .= '-' . $filters['envelope']; + if ($filters['service']) $name .= '-' . \Str::slug($filters['service']); + return $name . '.' . $ext; + } + + /** + * Valide et extrait les filtres d'export depuis la requête. + * Les valeurs non reconnues sont ignorées pour éviter toute injection de paramètres. + */ + private function resolveExportFilters(Request $request): array + { + $type = $request->get('type'); + $envelope = $request->get('envelope'); + + return [ + 'type' => in_array($type, ['investissement', 'fonctionnement']) ? $type : null, + 'envelope' => in_array($envelope, ['agglo', 'mutualise']) ? $envelope : null, + 'service' => $request->get('service') !== 'all' ? $request->get('service') : null, + ]; + } + + public function exportExcel(Request $request) + { + $annee = (int) $request->get('annee', now()->year); + $filters = $this->resolveExportFilters($request); + $filename = $this->getExportFilename('budget-execution', $annee, $filters, 'xlsx'); + return Excel::download(new BudgetExecutionExport($request->user(), $annee, $filters), $filename); + } + + public function exportOds(Request $request) + { + $annee = (int) $request->get('annee', now()->year); + $filters = $this->resolveExportFilters($request); + $filename = $this->getExportFilename('budget-execution', $annee, $filters, 'ods'); + return Excel::download(new BudgetExecutionExport($request->user(), $annee, $filters), $filename, \Maatwebsite\Excel\Excel::ODS); + } + + public function exportPdf(Request $request) + { + $user = $request->user(); + $annee = (int) $request->get('annee', now()->year); + $filters = $this->resolveExportFilters($request); + + $query = LigneBudget::with(['budget.service', 'commandes']) + ->whereHas('budget', function($q) use ($annee) { + $q->where('annee', $annee); + }); + + if (!$user->hasRole(['admin', 'directeur', 'raf'])) { + $query->whereHas('budget', function($q) use ($user) { + $q->where('service_id', $user->service_id); + }); + } + + if ($filters['type']) { + $query->where('type_depense', $filters['type']); + } + if ($filters['envelope']) { + $query->whereHas('budget', fn($q) => $q->where('type_budget', $filters['envelope'])); + } + if ($filters['service']) { + $query->whereHas('budget.service', fn($q) => $q->where('nom', $filters['service'])); + } + + $statutsConsommes = ['validee', 'commandee', 'partiellement_recue', 'recue_complete', 'cloturee']; + $statutsEngages = ['brouillon', 'en_attente_validation']; + + $lignes = $query->get()->map(function($lb) use ($statutsConsommes, $statutsEngages) { + $commandes = $lb->commandes; + + $lb->consomme = (float) $commandes->whereIn('statut', $statutsConsommes)->sum('montant_ttc'); + $lb->engage = (float) $commandes->whereIn('statut', $statutsEngages)->sum('montant_ttc'); + $lb->total_cumule = $lb->consomme + $lb->engage; + $lb->reste = (float) ($lb->montant_arbitre ?? 0) - $lb->total_cumule; + return $lb; + }); + + $pdf = Pdf::loadView('exports.budgets_pdf', [ + 'lignes' => $lignes, + 'annee' => $annee, + 'user' => $user, + 'type' => $filters['type'] ? ucfirst($filters['type']) : 'Tous types', + 'envelope' => $filters['envelope'] ? ucfirst($filters['envelope']) : 'Toutes enveloppes', + 'service' => !$user->hasRole(['admin', 'directeur', 'raf']) + ? ($user->service->nom ?? 'Inconnu') + : ($filters['service'] ?? 'Tous les services'), + ])->setPaper('a4', 'landscape'); + + $filename = $this->getExportFilename('rapport-budgetaire', $annee, $filters, 'pdf'); + return $pdf->download($filename); + } +} diff --git a/app/Http/Controllers/CommandeController.php b/app/Http/Controllers/CommandeController.php index 138cde0..e3e8ac7 100644 --- a/app/Http/Controllers/CommandeController.php +++ b/app/Http/Controllers/CommandeController.php @@ -7,6 +7,7 @@ use App\Models\Categorie; use App\Models\Commande; use App\Models\Fournisseur; use App\Models\Service; +use App\Models\LigneBudget; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; use Illuminate\Support\Facades\DB; @@ -54,6 +55,7 @@ class CommandeController extends Controller 'communes' => \App\Models\Commune::orderBy('nom')->get(), 'categories' => Categorie::active()->orderBy('ordre')->get(), 'articles' => Article::active()->with('categorie')->orderBy('designation')->get(), + 'lignesBudget' => LigneBudget::with('budget.service')->orderByDesc('created_at')->get(), ]); } @@ -71,6 +73,7 @@ class CommandeController extends Controller 'priorite' => 'required|in:normale,haute,urgente', 'reference_fournisseur' => 'nullable|string|max:100', 'imputation_budgetaire' => 'nullable|string|max:100', + 'ligne_budget_id' => 'nullable|exists:ligne_budgets,id', 'date_demande' => 'required|date', 'date_souhaitee' => 'nullable|date|after_or_equal:date_demande', 'date_livraison_prevue' => 'nullable|date', @@ -88,6 +91,18 @@ class CommandeController extends Controller 'lignes.*.notes' => 'nullable|string', ]); + // Vérification du budget disponible avant création + if (!empty($validated['ligne_budget_id'])) { + $montantEstime = $this->estimerMontantTtc($validated); + $budgetError = $this->checkBudgetDisponible( + $validated['ligne_budget_id'], + $montantEstime + ); + if ($budgetError) { + return back()->withErrors(['ligne_budget_id' => $budgetError])->withInput(); + } + } + DB::transaction(function () use ($validated, $request) { $commande = Commande::create([ ...$validated, @@ -115,6 +130,7 @@ class CommandeController extends Controller 'historique.user', 'piecesJointes.user', 'assets', + 'ligneBudget.budget.service' ]); $transitionsDisponibles = collect(Commande::STATUT_TRANSITIONS[$commande->statut] ?? []) @@ -140,6 +156,7 @@ class CommandeController extends Controller 'communes' => \App\Models\Commune::orderBy('nom')->get(), 'categories' => Categorie::active()->orderBy('ordre')->get(), 'articles' => Article::active()->with('categorie')->orderBy('designation')->get(), + 'lignesBudget' => LigneBudget::with('budget.service')->orderByDesc('created_at')->get(), ]); } @@ -157,6 +174,7 @@ class CommandeController extends Controller 'priorite' => 'required|in:normale,haute,urgente', 'reference_fournisseur' => 'nullable|string|max:100', 'imputation_budgetaire' => 'nullable|string|max:100', + 'ligne_budget_id' => 'nullable|exists:ligne_budgets,id', 'date_demande' => 'required|date', 'date_souhaitee' => 'nullable|date', 'date_livraison_prevue' => 'nullable|date', @@ -176,6 +194,19 @@ class CommandeController extends Controller 'lignes.*.notes' => 'nullable|string', ]); + // Vérification du budget disponible si la ligne budget change ou si le montant change + if (!empty($validated['ligne_budget_id'])) { + $montantEstime = $this->estimerMontantTtc($validated); + $budgetError = $this->checkBudgetDisponible( + $validated['ligne_budget_id'], + $montantEstime, + $commande->id + ); + if ($budgetError) { + return back()->withErrors(['ligne_budget_id' => $budgetError])->withInput(); + } + } + DB::transaction(function () use ($validated, $commande) { $commande->update($validated); @@ -236,6 +267,61 @@ class CommandeController extends Controller } } + /** + * Estime le montant TTC total depuis les lignes soumises dans la requête. + * Utilisé avant la création/mise à jour pour vérifier le budget disponible. + */ + private function estimerMontantTtc(array $validated): float + { + $total = 0.0; + foreach ($validated['lignes'] ?? [] as $ligne) { + $ht = (float) ($ligne['prix_unitaire_ht'] ?? 0); + $qty = (float) ($ligne['quantite'] ?? 1); + $tva = (float) ($ligne['taux_tva'] ?? 20); + $total += $ht * $qty * (1 + $tva / 100); + } + return $total; + } + + /** + * Vérifie que la ligne budgétaire a suffisamment de disponible pour accueillir le montant. + * Retourne un message d'erreur ou null si tout va bien. + * Le paramètre $excludeCommandeId permet d'exclure la commande en cours d'édition du calcul. + */ + private function checkBudgetDisponible(int $ligneBudgetId, float $montantTtc, ?int $excludeCommandeId = null): ?string + { + $ligne = LigneBudget::find($ligneBudgetId); + if (!$ligne || $ligne->montant_arbitre === null) { + return null; // Pas encore arbitrée, pas de blocage + } + + // Recalcule le disponible en excluant éventuellement la commande en cours d'édition, + // en utilisant les mêmes statuts que getMontantDisponibleAttribute pour la cohérence. + $statutsConsommes = ['validee', 'commandee', 'partiellement_recue', 'recue_complete', 'cloturee']; + $statutsEngages = ['brouillon', 'en_attente_validation']; + + $queryConsomme = $ligne->commandes()->whereIn('statut', $statutsConsommes); + $queryEngage = $ligne->commandes()->whereIn('statut', $statutsEngages); + if ($excludeCommandeId) { + $queryConsomme->where('id', '!=', $excludeCommandeId); + $queryEngage->where('id', '!=', $excludeCommandeId); + } + $disponible = (float) $ligne->montant_arbitre + - (float) $queryConsomme->sum('montant_ttc') + - (float) $queryEngage->sum('montant_ttc'); + + if ($montantTtc > $disponible) { + return sprintf( + 'Budget insuffisant sur "%s". Disponible : %s €, demandé : %s €.', + $ligne->nom, + number_format($disponible, 2, ',', ' '), + number_format($montantTtc, 2, ',', ' ') + ); + } + + return null; + } + public function exportPdf(Commande $commande) { $this->authorize('view', $commande); diff --git a/app/Http/Controllers/LigneBudgetController.php b/app/Http/Controllers/LigneBudgetController.php new file mode 100644 index 0000000..48677aa --- /dev/null +++ b/app/Http/Controllers/LigneBudgetController.php @@ -0,0 +1,98 @@ +validate([ + 'budget_id' => 'required|exists:budgets,id', + 'nom' => 'required|string|max:255', + 'description' => 'nullable|string', + 'type_depense' => 'required|in:investissement,fonctionnement', + 'montant_propose' => 'required|numeric|min:0', + 'commune_id' => 'nullable|exists:communes,id', + ]); + + $budget = Budget::findOrFail($request->budget_id); + + $this->authorize('addLigne', $budget); + + LigneBudget::create($request->only([ + 'budget_id', 'nom', 'description', 'type_depense', 'montant_propose', 'commune_id', + ])); + + return redirect()->back()->with('success', 'Ligne budgétaire ajoutée.'); + } + + public function update(Request $request, LigneBudget $lignes_budget) + { + $this->authorize('updateLigne', $lignes_budget); + + $request->validate([ + 'nom' => 'required|string|max:255', + 'description' => 'nullable|string', + 'type_depense' => 'required|in:investissement,fonctionnement', + 'montant_propose' => 'required|numeric|min:0', + 'commune_id' => 'nullable|exists:communes,id', + ]); + + $lignes_budget->update($request->only([ + 'nom', 'description', 'type_depense', 'montant_propose', 'commune_id', + ])); + + return redirect()->back()->with('success', 'Ligne budgétaire mise à jour.'); + } + + public function arbitrer(Request $request, LigneBudget $ligneBudget) + { + $this->authorize('arbitrerLigne', $ligneBudget); + + $request->validate([ + 'statut_arbitrage' => 'required|in:propose,accepte_dsi,accepte_direction,valide_definitif,refuse,reporte', + 'commentaire' => 'nullable|string|max:1000', + 'montant_arbitre' => [ + 'nullable', + 'numeric', + 'min:0', + function ($attribute, $value, $fail) use ($ligneBudget) { + if ($value !== null && $value > $ligneBudget->montant_propose) { + $fail("Le montant arbitré ({$value} €) ne peut pas dépasser le montant proposé ({$ligneBudget->montant_propose} €)."); + } + }, + ], + ]); + + HistoriqueBudget::create([ + 'ligne_budget_id' => $ligneBudget->id, + 'user_id' => $request->user()->id, + 'ancien_statut' => $ligneBudget->statut_arbitrage, + 'nouveau_statut' => $request->statut_arbitrage, + 'ancien_montant_arbitre' => $ligneBudget->montant_arbitre, + 'nouveau_montant_arbitre'=> $request->montant_arbitre, + 'commentaire' => $request->commentaire, + ]); + + $ligneBudget->update([ + 'statut_arbitrage' => $request->statut_arbitrage, + 'montant_arbitre' => $request->montant_arbitre, + ]); + + return redirect()->back()->with('success', 'Arbitrage enregistré.'); + } + + public function destroy(LigneBudget $lignes_budget) + { + $this->authorize('deleteLigne', $lignes_budget); + + $lignes_budget->delete(); + + return redirect()->back()->with('success', 'Ligne budgétaire supprimée.'); + } +} diff --git a/app/Models/Budget.php b/app/Models/Budget.php new file mode 100644 index 0000000..15c31a6 --- /dev/null +++ b/app/Models/Budget.php @@ -0,0 +1,60 @@ +belongsTo(Service::class); + } + + public function commune(): BelongsTo + { + return $this->belongsTo(Commune::class); + } + + public function lignes(): HasMany + { + return $this->hasMany(LigneBudget::class); + } + + /** + * Calcul du total proposé selon le type de dépense (optionnel: 'investissement' ou 'fonctionnement'). + */ + public function totalPropose(?string $typeDepense = null): float + { + $query = $this->lignes(); + if ($typeDepense) { + $query->where('type_depense', $typeDepense); + } + return $query->sum('montant_propose') ?? 0; + } + + /** + * Calcul du total arbitré (validé) selon le type de dépense. + */ + public function totalArbitre(?string $typeDepense = null): float + { + $query = $this->lignes(); + if ($typeDepense) { + $query->where('type_depense', $typeDepense); + } + return $query->sum('montant_arbitre') ?? 0; + } +} diff --git a/app/Models/HistoriqueBudget.php b/app/Models/HistoriqueBudget.php new file mode 100644 index 0000000..ffe207c --- /dev/null +++ b/app/Models/HistoriqueBudget.php @@ -0,0 +1,34 @@ + 'decimal:2', + 'nouveau_montant_arbitre' => 'decimal:2', + ]; + + public function ligneBudget(): BelongsTo + { + return $this->belongsTo(LigneBudget::class); + } + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } +} diff --git a/app/Models/LigneBudget.php b/app/Models/LigneBudget.php new file mode 100644 index 0000000..bf465f6 --- /dev/null +++ b/app/Models/LigneBudget.php @@ -0,0 +1,85 @@ + 'decimal:2', + 'montant_arbitre' => 'decimal:2', + ]; + + public function budget(): BelongsTo + { + return $this->belongsTo(Budget::class); + } + + public function commune(): BelongsTo + { + return $this->belongsTo(Commune::class); + } + + public function commandes(): HasMany + { + return $this->hasMany(Commande::class, 'ligne_budget_id'); + } + + public function historique(): HasMany + { + return $this->hasMany(HistoriqueBudget::class)->latest(); + } + + /** + * Calcule le total des commandes réelles liées à cette ligne + */ + public function getMontantConsommeAttribute(): float + { + // On somme le montant total des commandes validées ou plus loin + return $this->commandes() + ->whereIn('statut', ['validee', 'commandee', 'partiellement_recue', 'recue_complete', 'cloturee']) + ->sum('montant_ttc') ?? 0; + } + + /** + * Calcule le total des commandes en brouillon ou attente + */ + public function getMontantEngageAttribute(): float + { + return $this->commandes() + ->whereIn('statut', ['brouillon', 'en_attente_validation']) + ->sum('montant_ttc') ?? 0; + } + + /** + * Calcule le montant restant (Arbitré - Consommé - Engagé). + * Retourne 0.0 si aucun montant n'a encore été arbitré. + */ + public function getMontantDisponibleAttribute(): float + { + return (float) ($this->montant_arbitre ?? 0) - ($this->montant_consomme + $this->montant_engage); + } +} diff --git a/app/Models/Service.php b/app/Models/Service.php index f1b577c..608c31e 100644 --- a/app/Models/Service.php +++ b/app/Models/Service.php @@ -2,11 +2,14 @@ namespace App\Models; +use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\HasMany; class Service extends Model { + use HasFactory; + protected $fillable = ['nom', 'description', 'couleur', 'icone']; public function users(): HasMany diff --git a/app/Policies/BudgetPolicy.php b/app/Policies/BudgetPolicy.php new file mode 100644 index 0000000..f84a50b --- /dev/null +++ b/app/Policies/BudgetPolicy.php @@ -0,0 +1,110 @@ +hasRole(['admin', 'directeur', 'raf']) + || $budget->service_id === $user->service_id; + } + + /** + * Seuls les admin et directeur peuvent créer des budgets. + */ + public function create(User $user): bool + { + return $user->hasRole(['admin', 'directeur', 'raf']); + } + + /** + * Seuls les admin et directeur peuvent changer le statut d'un budget. + */ + public function update(User $user, Budget $budget): bool + { + return $user->hasRole(['admin', 'directeur']); + } + + /** + * Seuls les admin peuvent supprimer un budget. + */ + public function delete(User $user, Budget $budget): bool + { + return $user->hasRole('admin'); + } + + /** + * Un utilisateur peut ajouter une ligne budgétaire sur un budget + * si le budget lui appartient (son service) ou s'il est admin/directeur/raf, + * et si le budget n'est pas verrouillé. + */ + public function addLigne(User $user, Budget $budget): bool + { + if (in_array($budget->statut, ['arbitrage_direction', 'valide', 'cloture'])) { + return false; + } + + return $user->hasRole(['admin', 'directeur', 'raf']) + || $budget->service_id === $user->service_id; + } + + /** + * Un utilisateur peut modifier une ligne budgétaire si : + * - le budget parent lui appartient (son service) ou il est admin/directeur/raf + * - le budget n'est pas verrouillé (arbitrage_direction, valide, cloture) + */ + public function updateLigne(User $user, LigneBudget $ligneBudget): bool + { + $budget = $ligneBudget->budget; + + if (!$budget || in_array($budget->statut, ['arbitrage_direction', 'valide', 'cloture'])) { + return false; + } + + return $user->hasRole(['admin', 'directeur', 'raf']) + || $budget->service_id === $user->service_id; + } + + /** + * Un utilisateur peut supprimer une ligne budgétaire si : + * - le budget parent lui appartient (son service) ou il est admin/directeur/raf + * - le budget n'est pas verrouillé + */ + public function deleteLigne(User $user, LigneBudget $ligneBudget): bool + { + $budget = $ligneBudget->budget; + + if (!$budget || in_array($budget->statut, ['arbitrage_direction', 'valide', 'cloture'])) { + return false; + } + + return $user->hasRole(['admin', 'directeur', 'raf']) + || $budget->service_id === $user->service_id; + } + + /** + * Seuls les admin, directeur et raf peuvent arbitrer les lignes budgétaires. + */ + public function arbitrerLigne(User $user, LigneBudget $ligneBudget): bool + { + return $user->hasRole(['admin', 'directeur', 'raf']); + } +} diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 2b36742..266404e 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -2,11 +2,14 @@ namespace App\Providers; +use App\Models\Budget; use App\Models\Categorie; use App\Models\Commande; use App\Models\Fournisseur; +use App\Models\LigneBudget; use App\Models\PieceJointe; use App\Models\User; +use App\Policies\BudgetPolicy; use App\Policies\CommandePolicy; use App\Policies\FournisseurPolicy; use App\Policies\PieceJointePolicy; @@ -18,6 +21,8 @@ use Illuminate\Support\Facades\Vite; class AppServiceProvider extends ServiceProvider { protected $policies = [ + Budget::class => BudgetPolicy::class, + LigneBudget::class => BudgetPolicy::class, Commande::class => CommandePolicy::class, Fournisseur::class => FournisseurPolicy::class, PieceJointe::class => PieceJointePolicy::class, diff --git a/database/factories/ServiceFactory.php b/database/factories/ServiceFactory.php new file mode 100644 index 0000000..4adf45c --- /dev/null +++ b/database/factories/ServiceFactory.php @@ -0,0 +1,24 @@ + + */ +class ServiceFactory extends Factory +{ + protected $model = Service::class; + + public function definition(): array + { + return [ + 'nom' => fake()->unique()->words(2, true), + 'description' => fake()->sentence(), + 'couleur' => fake()->hexColor(), + 'icone' => 'folder', + ]; + } +} diff --git a/database/migrations/2026_04_11_084326_create_budgets_table.php b/database/migrations/2026_04_11_084326_create_budgets_table.php new file mode 100644 index 0000000..1cf15cf --- /dev/null +++ b/database/migrations/2026_04_11_084326_create_budgets_table.php @@ -0,0 +1,28 @@ +id(); + $table->foreignId('service_id')->constrained()->cascadeOnDelete(); + $table->integer('annee'); + $table->enum('type_budget', ['agglo', 'mutualise'])->default('agglo'); + $table->enum('statut', ['preparation', 'arbitrage_dsi', 'arbitrage_direction', 'valide', 'cloture'])->default('preparation'); + $table->timestamps(); + + // Un service ne peut avoir qu'un seul budget d'un type donné par an + $table->unique(['service_id', 'annee', 'type_budget']); + }); + } + + public function down(): void + { + Schema::dropIfExists('budgets'); + } +}; diff --git a/database/migrations/2026_04_11_084327_create_ligne_budgets_table.php b/database/migrations/2026_04_11_084327_create_ligne_budgets_table.php new file mode 100644 index 0000000..347efda --- /dev/null +++ b/database/migrations/2026_04_11_084327_create_ligne_budgets_table.php @@ -0,0 +1,28 @@ +id(); + $table->foreignId('budget_id')->constrained()->cascadeOnDelete(); + $table->string('nom'); + $table->text('description')->nullable(); + $table->enum('type_depense', ['investissement', 'fonctionnement'])->default('fonctionnement'); + $table->decimal('montant_propose', 10, 2)->default(0); + $table->decimal('montant_arbitre', 10, 2)->nullable(); + $table->enum('statut_arbitrage', ['brouillon', 'propose', 'accepte_dsi', 'accepte_direction', 'valide_definitif', 'refuse', 'reporte'])->default('brouillon'); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('ligne_budgets'); + } +}; diff --git a/database/migrations/2026_04_11_084329_add_ligne_budget_id_to_commandes_table.php b/database/migrations/2026_04_11_084329_add_ligne_budget_id_to_commandes_table.php new file mode 100644 index 0000000..cea50e6 --- /dev/null +++ b/database/migrations/2026_04_11_084329_add_ligne_budget_id_to_commandes_table.php @@ -0,0 +1,23 @@ +foreignId('ligne_budget_id')->nullable()->constrained('ligne_budgets')->nullOnDelete(); + }); + } + + public function down(): void + { + Schema::table('commandes', function (Blueprint $table) { + $table->dropForeign(['ligne_budget_id']); + $table->dropColumn('ligne_budget_id'); + }); + } +}; diff --git a/database/migrations/2026_04_11_095851_add_commune_id_to_budgets_table.php b/database/migrations/2026_04_11_095851_add_commune_id_to_budgets_table.php new file mode 100644 index 0000000..49a25e7 --- /dev/null +++ b/database/migrations/2026_04_11_095851_add_commune_id_to_budgets_table.php @@ -0,0 +1,29 @@ +foreignId('commune_id')->nullable()->after('service_id')->constrained('communes')->nullOnDelete(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('budgets', function (Blueprint $table) { + $table->dropForeign(['commune_id']); + $table->dropColumn('commune_id'); + }); + } +}; diff --git a/database/migrations/2026_04_11_100456_add_commune_id_to_ligne_budgets_table.php b/database/migrations/2026_04_11_100456_add_commune_id_to_ligne_budgets_table.php new file mode 100644 index 0000000..815f67f --- /dev/null +++ b/database/migrations/2026_04_11_100456_add_commune_id_to_ligne_budgets_table.php @@ -0,0 +1,29 @@ +foreignId('commune_id')->nullable()->after('budget_id')->constrained('communes')->nullOnDelete(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('ligne_budgets', function (Blueprint $table) { + $table->dropForeign(['commune_id']); + $table->dropColumn('commune_id'); + }); + } +}; diff --git a/database/migrations/2026_04_11_121337_create_raf_role.php b/database/migrations/2026_04_11_121337_create_raf_role.php new file mode 100644 index 0000000..547d0c9 --- /dev/null +++ b/database/migrations/2026_04_11_121337_create_raf_role.php @@ -0,0 +1,25 @@ + 'raf', 'guard_name' => 'web']); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Role::where('name', 'raf')->delete(); + } +}; diff --git a/database/migrations/2026_04_11_162916_create_historique_budgets_table.php b/database/migrations/2026_04_11_162916_create_historique_budgets_table.php new file mode 100644 index 0000000..f35fa87 --- /dev/null +++ b/database/migrations/2026_04_11_162916_create_historique_budgets_table.php @@ -0,0 +1,36 @@ +id(); + $table->foreignId('ligne_budget_id')->constrained('ligne_budgets')->cascadeOnDelete(); + $table->foreignId('user_id')->constrained('users')->restrictOnDelete(); + + $table->string('ancien_statut')->nullable(); + $table->string('nouveau_statut'); + $table->decimal('ancien_montant_arbitre', 10, 2)->nullable(); + $table->decimal('nouveau_montant_arbitre', 10, 2)->nullable(); + $table->text('commentaire')->nullable(); + + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('historique_budgets'); + } +}; diff --git a/package-lock.json b/package-lock.json index 0e04495..fdda944 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5,6 +5,11 @@ "packages": { "": { "dependencies": { + "@fortawesome/fontawesome-svg-core": "^7.2.0", + "@fortawesome/free-brands-svg-icons": "^7.2.0", + "@fortawesome/free-regular-svg-icons": "^7.2.0", + "@fortawesome/free-solid-svg-icons": "^7.2.0", + "@fortawesome/vue-fontawesome": "^3.1.3", "@fullcalendar/core": "^6.1.20", "@fullcalendar/daygrid": "^6.1.20", "@fullcalendar/interaction": "^6.1.20", @@ -118,6 +123,73 @@ "tslib": "^2.4.0" } }, + "node_modules/@fortawesome/fontawesome-common-types": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-common-types/-/fontawesome-common-types-7.2.0.tgz", + "integrity": "sha512-IpR0bER9FY25p+e7BmFH25MZKEwFHTfRAfhOyJubgiDnoJNsSvJ7nigLraHtp4VOG/cy8D7uiV0dLkHOne5Fhw==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/@fortawesome/fontawesome-svg-core": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-svg-core/-/fontawesome-svg-core-7.2.0.tgz", + "integrity": "sha512-6639htZMjEkwskf3J+e6/iar+4cTNM9qhoWuRfj9F3eJD6r7iCzV1SWnQr2Mdv0QT0suuqU8BoJCZUyCtP9R4Q==", + "license": "MIT", + "dependencies": { + "@fortawesome/fontawesome-common-types": "7.2.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/@fortawesome/free-brands-svg-icons": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@fortawesome/free-brands-svg-icons/-/free-brands-svg-icons-7.2.0.tgz", + "integrity": "sha512-VNG8xqOip1JuJcC3zsVsKRQ60oXG9+oYNDCosjoU/H9pgYmLTEwWw8pE0jhPz/JWdHeUuK6+NQ3qsM4gIbdbYQ==", + "license": "(CC-BY-4.0 AND MIT)", + "dependencies": { + "@fortawesome/fontawesome-common-types": "7.2.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/@fortawesome/free-regular-svg-icons": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@fortawesome/free-regular-svg-icons/-/free-regular-svg-icons-7.2.0.tgz", + "integrity": "sha512-iycmlN51EULlQ4D/UU9WZnHiN0CvjJ2TuuCrAh+1MVdzD+4ViKYH2deNAll4XAAYlZa8WAefHR5taSK8hYmSMw==", + "license": "(CC-BY-4.0 AND MIT)", + "dependencies": { + "@fortawesome/fontawesome-common-types": "7.2.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/@fortawesome/free-solid-svg-icons": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@fortawesome/free-solid-svg-icons/-/free-solid-svg-icons-7.2.0.tgz", + "integrity": "sha512-YTVITFGN0/24PxzXrwqCgnyd7njDuzp5ZvaCx5nq/jg55kUYd94Nj8UTchBdBofi/L0nwRfjGOg0E41d2u9T1w==", + "license": "(CC-BY-4.0 AND MIT)", + "dependencies": { + "@fortawesome/fontawesome-common-types": "7.2.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/@fortawesome/vue-fontawesome": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@fortawesome/vue-fontawesome/-/vue-fontawesome-3.1.3.tgz", + "integrity": "sha512-OHHUTLPEzdwP8kcYIzhioUdUOjZ4zzmi+midwa4bqscza4OJCOvTKJEHkXNz8PgZ23kWci1HkKVX0bm8f9t9gQ==", + "license": "MIT", + "peerDependencies": { + "@fortawesome/fontawesome-svg-core": "~1 || ~6 || ~7", + "vue": ">= 3.0.0 < 4" + } + }, "node_modules/@fullcalendar/core": { "version": "6.1.20", "resolved": "https://registry.npmjs.org/@fullcalendar/core/-/core-6.1.20.tgz", diff --git a/package.json b/package.json index f523395..15aa2ea 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,11 @@ "vue": "^3.4.0" }, "dependencies": { + "@fortawesome/fontawesome-svg-core": "^7.2.0", + "@fortawesome/free-brands-svg-icons": "^7.2.0", + "@fortawesome/free-regular-svg-icons": "^7.2.0", + "@fortawesome/free-solid-svg-icons": "^7.2.0", + "@fortawesome/vue-fontawesome": "^3.1.3", "@fullcalendar/core": "^6.1.20", "@fullcalendar/daygrid": "^6.1.20", "@fullcalendar/interaction": "^6.1.20", diff --git a/resources/js/Components/SearchableSelect.vue b/resources/js/Components/SearchableSelect.vue new file mode 100644 index 0000000..c48e26a --- /dev/null +++ b/resources/js/Components/SearchableSelect.vue @@ -0,0 +1,134 @@ + + + diff --git a/resources/js/Pages/Budgets/Execution.vue b/resources/js/Pages/Budgets/Execution.vue new file mode 100644 index 0000000..d1b22c7 --- /dev/null +++ b/resources/js/Pages/Budgets/Execution.vue @@ -0,0 +1,298 @@ + + + diff --git a/resources/js/Pages/Budgets/Index.vue b/resources/js/Pages/Budgets/Index.vue new file mode 100644 index 0000000..7ca1722 --- /dev/null +++ b/resources/js/Pages/Budgets/Index.vue @@ -0,0 +1,350 @@ + + + diff --git a/resources/js/Pages/Budgets/Show.vue b/resources/js/Pages/Budgets/Show.vue new file mode 100644 index 0000000..a73a9b2 --- /dev/null +++ b/resources/js/Pages/Budgets/Show.vue @@ -0,0 +1,416 @@ + + + diff --git a/resources/js/Pages/Commandes/Create.vue b/resources/js/Pages/Commandes/Create.vue index 396710e..efd6b66 100644 --- a/resources/js/Pages/Commandes/Create.vue +++ b/resources/js/Pages/Commandes/Create.vue @@ -1,6 +1,8 @@ diff --git a/resources/js/app.js b/resources/js/app.js index a2a2839..8464d48 100644 --- a/resources/js/app.js +++ b/resources/js/app.js @@ -6,6 +6,14 @@ import { resolvePageComponent } from 'laravel-vite-plugin/inertia-helpers'; import { createApp, h } from 'vue'; import { ZiggyVue } from '../../vendor/tightenco/ziggy'; +// FontAwesome +import { library } from '@fortawesome/fontawesome-svg-core'; +import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome'; +import { fas } from '@fortawesome/free-solid-svg-icons'; +import { far } from '@fortawesome/free-regular-svg-icons'; +import { fab } from '@fortawesome/free-brands-svg-icons'; +library.add(fas, far, fab); + const appName = import.meta.env.VITE_APP_NAME || 'Laravel'; createInertiaApp({ @@ -19,6 +27,7 @@ createInertiaApp({ return createApp({ render: () => h(App, props) }) .use(plugin) .use(ZiggyVue) + .component('font-awesome-icon', FontAwesomeIcon) .mount(el); }, progress: { diff --git a/tests/Feature/BudgetMetierTest.php b/tests/Feature/BudgetMetierTest.php new file mode 100644 index 0000000..6d3b01d --- /dev/null +++ b/tests/Feature/BudgetMetierTest.php @@ -0,0 +1,255 @@ + $name, 'guard_name' => 'web']); + } + + private function userWithRole(string $role, ?Service $service = null): User + { + $service ??= Service::factory()->create(); + $user = User::factory()->create(['service_id' => $service->id, 'email_verified_at' => now()]); + $user->assignRole($this->createRole($role)); + return $user; + } + + private function makeBudget(Service $service, string $statut = 'preparation'): Budget + { + return Budget::create([ + 'service_id' => $service->id, + 'annee' => now()->year, + 'type_budget' => 'agglo', + 'statut' => $statut, + ]); + } + + private function makeLigne(Budget $budget, float $propose = 5000, ?float $arbitre = null): LigneBudget + { + return LigneBudget::create([ + 'budget_id' => $budget->id, + 'nom' => 'Ligne test', + 'type_depense' => 'investissement', + 'montant_propose' => $propose, + 'montant_arbitre' => $arbitre, + ]); + } + + private function makeCommande(array $overrides = []): \App\Models\Commande + { + static $counter = 1; + $service = $overrides['service_id'] ?? Service::factory()->create()->id; + $user = $overrides['user_id'] ?? User::factory()->create(['service_id' => $service, 'email_verified_at' => now()])->id; + + return \App\Models\Commande::create(array_merge([ + 'service_id' => $service, + 'user_id' => $user, + 'numero_commande' => 'CMD-METIER-' . $counter++, + 'objet' => 'Commande test', + 'statut' => 'brouillon', + 'montant_ht' => 1000, + 'montant_ttc' => 1200, + 'date_demande' => now()->toDateString(), + ], $overrides)); + } + + // ------------------------------------------------------------------------- + // Historique des arbitrages + // ------------------------------------------------------------------------- + + public function test_arbitrage_creates_historique_entry(): void + { + $service = Service::factory()->create(); + $raf = $this->userWithRole('raf', $service); + $budget = $this->makeBudget($service); + $ligne = $this->makeLigne($budget, 1000); + + $this->actingAs($raf)->patch(route('lignes-budget.arbitrer', $ligne), [ + 'statut_arbitrage' => 'accepte_dsi', + 'montant_arbitre' => 800, + 'commentaire' => 'Réduit suite révision priorités', + ]); + + $this->assertDatabaseHas('historique_budgets', [ + 'ligne_budget_id' => $ligne->id, + 'user_id' => $raf->id, + 'ancien_statut' => 'brouillon', + 'nouveau_statut' => 'accepte_dsi', + 'ancien_montant_arbitre' => null, + 'nouveau_montant_arbitre' => 800, + 'commentaire' => 'Réduit suite révision priorités', + ]); + } + + public function test_multiple_arbitrages_build_full_history(): void + { + $service = Service::factory()->create(); + $raf = $this->userWithRole('raf', $service); + $budget = $this->makeBudget($service); + $ligne = $this->makeLigne($budget, 1000); + + $this->actingAs($raf)->patch(route('lignes-budget.arbitrer', $ligne), [ + 'statut_arbitrage' => 'accepte_dsi', + 'montant_arbitre' => 800, + ]); + + $this->actingAs($raf)->patch(route('lignes-budget.arbitrer', $ligne), [ + 'statut_arbitrage' => 'valide_definitif', + 'montant_arbitre' => 750, + ]); + + $this->assertSame(2, HistoriqueBudget::where('ligne_budget_id', $ligne->id)->count()); + + // Le deuxième arbitrage (le plus récent) doit tracer le passage de accepte_dsi → valide_definitif + $last = HistoriqueBudget::where('ligne_budget_id', $ligne->id)->oldest()->skip(1)->first(); + $this->assertEquals('accepte_dsi', $last->ancien_statut); + $this->assertEquals('valide_definitif', $last->nouveau_statut); + $this->assertEquals(800, (float) $last->ancien_montant_arbitre); + $this->assertEquals(750, (float) $last->nouveau_montant_arbitre); + } + + public function test_historique_is_included_in_budget_show(): void + { + $service = Service::factory()->create(); + $raf = $this->userWithRole('raf', $service); + $budget = $this->makeBudget($service); + $ligne = $this->makeLigne($budget, 1000); + + $this->actingAs($raf)->patch(route('lignes-budget.arbitrer', $ligne), [ + 'statut_arbitrage' => 'accepte_dsi', + 'montant_arbitre' => 800, + 'commentaire' => 'Premier arbitrage', + ]); + + $response = $this->actingAs($raf)->get(route('budgets.show', $budget)); + + $response->assertOk(); + $response->assertInertia(fn ($page) => + $page->has('lignes.0.historique', 1) + ->where('lignes.0.historique.0.commentaire', 'Premier arbitrage') + ->where('lignes.0.historique.0.user', $raf->name) + ); + } + + public function test_historique_deleted_when_ligne_deleted(): void + { + $service = Service::factory()->create(); + $raf = $this->userWithRole('raf', $service); + $budget = $this->makeBudget($service); + $ligne = $this->makeLigne($budget, 1000); + + $this->actingAs($raf)->patch(route('lignes-budget.arbitrer', $ligne), [ + 'statut_arbitrage' => 'accepte_dsi', + 'montant_arbitre' => 800, + ]); + + $ligneId = $ligne->id; + $this->actingAs($this->userWithRole('admin'))->delete(route('lignes-budget.destroy', $ligne)); + + $this->assertDatabaseMissing('historique_budgets', ['ligne_budget_id' => $ligneId]); + } + + // ------------------------------------------------------------------------- + // Blocage des commandes si budget insuffisant + // ------------------------------------------------------------------------- + + public function test_commande_creation_blocked_when_budget_exceeded(): void + { + $service = Service::factory()->create(); + $acheteur = $this->userWithRole('acheteur', $service); + $budget = $this->makeBudget($service); + $ligne = $this->makeLigne($budget, propose: 5000, arbitre: 1000); + + // Commande existante qui consomme 800 € TTC + $this->makeCommande([ + 'ligne_budget_id' => $ligne->id, + 'service_id' => $service->id, + 'user_id' => $acheteur->id, + 'montant_ttc' => 800, + 'statut' => 'validee', + ]); + + // Tentative de créer une commande avec une ligne à 500 € HT + 20% TVA = 600 € TTC + // Reste dispo : 1000 - 800 = 200 € → 600 > 200 → bloqué + $response = $this->actingAs($acheteur)->post(route('commandes.store'), [ + 'service_id' => $service->id, + 'objet' => 'Dépassement test', + 'priorite' => 'normale', + 'date_demande' => now()->toDateString(), + 'ligne_budget_id' => $ligne->id, + 'lignes' => [ + ['designation' => 'Article test', 'quantite' => 1, 'prix_unitaire_ht' => 500, 'taux_tva' => 20], + ], + ]); + + $response->assertSessionHasErrors('ligne_budget_id'); + } + + public function test_commande_creation_allowed_when_budget_sufficient(): void + { + $service = Service::factory()->create(); + $acheteur = $this->userWithRole('acheteur', $service); + $budget = $this->makeBudget($service); + $ligne = $this->makeLigne($budget, propose: 5000, arbitre: 2000); + + // Commande existante de 800 € TTC, reste 1200 € + $this->makeCommande([ + 'ligne_budget_id' => $ligne->id, + 'service_id' => $service->id, + 'user_id' => $acheteur->id, + 'montant_ttc' => 800, + 'statut' => 'validee', + ]); + + // Nouvelle ligne à 100 € HT + 20% = 120 € TTC → OK (800+120=920 < 2000) + $response = $this->actingAs($acheteur)->post(route('commandes.store'), [ + 'service_id' => $service->id, + 'objet' => 'Commande dans le budget', + 'priorite' => 'normale', + 'date_demande' => now()->toDateString(), + 'ligne_budget_id' => $ligne->id, + 'lignes' => [ + ['designation' => 'Article test', 'quantite' => 1, 'prix_unitaire_ht' => 100, 'taux_tva' => 20], + ], + ]); + + $response->assertSessionDoesntHaveErrors('ligne_budget_id'); + } + + public function test_commande_creation_not_blocked_when_ligne_not_yet_arbitree(): void + { + $service = Service::factory()->create(); + $acheteur = $this->userWithRole('acheteur', $service); + $budget = $this->makeBudget($service); + // Ligne sans montant_arbitre + $ligne = $this->makeLigne($budget, propose: 1000, arbitre: null); + + // Ligne à 9000 € HT → normalement dépasserait, mais pas arbitrée = pas de blocage + $response = $this->actingAs($acheteur)->post(route('commandes.store'), [ + 'service_id' => $service->id, + 'objet' => 'Commande avant arbitrage', + 'priorite' => 'normale', + 'date_demande' => now()->toDateString(), + 'ligne_budget_id' => $ligne->id, + 'lignes' => [ + ['designation' => 'Article test', 'quantite' => 1, 'prix_unitaire_ht' => 9000, 'taux_tva' => 20], + ], + ]); + + $response->assertSessionDoesntHaveErrors('ligne_budget_id'); + } +} diff --git a/tests/Feature/BudgetPerformanceTest.php b/tests/Feature/BudgetPerformanceTest.php new file mode 100644 index 0000000..1bfda6c --- /dev/null +++ b/tests/Feature/BudgetPerformanceTest.php @@ -0,0 +1,294 @@ + 'admin', 'guard_name' => 'web']); + $user = User::factory()->create([ + 'service_id' => Service::factory()->create()->id, + 'email_verified_at' => now(), + ]); + $user->assignRole('admin'); + return $user; + } + + private function rafUser(Service $service): User + { + Role::firstOrCreate(['name' => 'raf', 'guard_name' => 'web']); + $user = User::factory()->create([ + 'service_id' => $service->id, + 'email_verified_at' => now(), + ]); + $user->assignRole('raf'); + return $user; + } + + private function makeCommande(array $overrides = []): Commande + { + static $counter = 1; + $service = $overrides['service_id'] ?? Service::factory()->create()->id; + $user = $overrides['user_id'] ?? User::factory()->create(['service_id' => $service, 'email_verified_at' => now()])->id; + + return Commande::create(array_merge([ + 'service_id' => $service, + 'user_id' => $user, + 'numero_commande' => 'CMD-PERF-' . $counter++, + 'objet' => 'Commande de test', + 'statut' => 'brouillon', + 'montant_ht' => 1000, + 'montant_ttc' => 1200, + 'date_demande' => now()->toDateString(), + ], $overrides)); + } + + private function makeBudgetWithLignes(int $nbLignes = 3, int $commandesPerLigne = 2): array + { + $service = Service::factory()->create(); + $budget = Budget::create([ + 'service_id' => $service->id, + 'annee' => now()->year, + 'type_budget' => 'agglo', + 'statut' => 'preparation', + ]); + + $lignes = collect(); + for ($i = 0; $i < $nbLignes; $i++) { + $ligne = LigneBudget::create([ + 'budget_id' => $budget->id, + 'nom' => "Ligne $i", + 'type_depense' => $i % 2 === 0 ? 'investissement' : 'fonctionnement', + 'montant_propose' => 10000, + 'montant_arbitre' => 8000, + ]); + + for ($j = 0; $j < $commandesPerLigne; $j++) { + $this->makeCommande([ + 'ligne_budget_id' => $ligne->id, + 'service_id' => $service->id, + 'statut' => $j === 0 ? 'validee' : 'brouillon', + ]); + } + + $lignes->push($ligne); + } + + return [$budget, $lignes, $service]; + } + + // ------------------------------------------------------------------------- + // N+1 — execution() + // ------------------------------------------------------------------------- + + public function test_execution_does_not_trigger_n_plus_1_queries(): void + { + [$budget, $lignes, $service] = $this->makeBudgetWithLignes(nbLignes: 5, commandesPerLigne: 3); + $admin = $this->adminUser(); + + // Warmup — on ignore la première requête de setup + DB::flushQueryLog(); + DB::enableQueryLog(); + + $this->actingAs($admin)->get(route('budgets.execution', ['annee' => now()->year])); + + $queries = DB::getQueryLog(); + DB::disableQueryLog(); + + // Sans N+1 : ~5 requêtes max (session, user, lignes, eager loads) + // Avec N+1 : 3 requêtes × nb_lignes supplémentaires + // On vérifie qu'on reste sous un seuil raisonnable (15 = large marge) + $this->assertLessThan( + 15, + count($queries), + sprintf( + 'Trop de requêtes SQL dans execution() : %d requêtes pour 5 lignes. Probable N+1.', + count($queries) + ) + ); + } + + // ------------------------------------------------------------------------- + // N+1 — exportPdf() + // ------------------------------------------------------------------------- + + public function test_export_pdf_does_not_trigger_n_plus_1_queries(): void + { + [$budget, $lignes, $service] = $this->makeBudgetWithLignes(nbLignes: 5, commandesPerLigne: 3); + $admin = $this->adminUser(); + + DB::flushQueryLog(); + DB::enableQueryLog(); + + $this->actingAs($admin)->get(route('budgets.export.pdf', ['annee' => now()->year])); + + $queries = DB::getQueryLog(); + DB::disableQueryLog(); + + $this->assertLessThan( + 15, + count($queries), + sprintf( + 'Trop de requêtes SQL dans exportPdf() : %d requêtes pour 5 lignes. Probable N+1.', + count($queries) + ) + ); + } + + // ------------------------------------------------------------------------- + // null-safety — montant_disponible quand montant_arbitre est null + // ------------------------------------------------------------------------- + + public function test_montant_disponible_is_zero_when_montant_arbitre_is_null(): void + { + $service = Service::factory()->create(); + $budget = Budget::create([ + 'service_id' => $service->id, + 'annee' => now()->year, + 'type_budget' => 'agglo', + 'statut' => 'preparation', + ]); + + $ligne = LigneBudget::create([ + 'budget_id' => $budget->id, + 'nom' => 'Ligne sans arbitrage', + 'type_depense' => 'investissement', + 'montant_propose' => 5000, + 'montant_arbitre' => null, + ]); + + // Avant la correction, ceci retournait une valeur imprévisible ou une erreur + $this->assertSame(0.0, $ligne->montant_disponible); + $this->assertIsFloat($ligne->montant_disponible); + } + + public function test_montant_disponible_is_correct_with_arbitre_set(): void + { + $service = Service::factory()->create(); + $budget = Budget::create([ + 'service_id' => $service->id, + 'annee' => now()->year, + 'type_budget' => 'agglo', + 'statut' => 'preparation', + ]); + + $ligne = LigneBudget::create([ + 'budget_id' => $budget->id, + 'nom' => 'Ligne arbitrée', + 'type_depense' => 'investissement', + 'montant_propose' => 5000, + 'montant_arbitre' => 4000, + ]); + + // Pas de commandes : disponible = arbitré = 4000 + $this->assertSame(4000.0, $ligne->montant_disponible); + } + + // ------------------------------------------------------------------------- + // Validation montant_arbitre <= montant_propose + // ------------------------------------------------------------------------- + + public function test_arbitrage_rejects_montant_arbitre_exceeding_montant_propose(): void + { + Role::firstOrCreate(['name' => 'raf', 'guard_name' => 'web']); + $service = Service::factory()->create(); + $raf = $this->rafUser($service); + $budget = Budget::create([ + 'service_id' => $service->id, + 'annee' => now()->year, + 'type_budget' => 'agglo', + 'statut' => 'arbitrage_dsi', + ]); + $ligne = LigneBudget::create([ + 'budget_id' => $budget->id, + 'nom' => 'Ligne à arbitrer', + 'type_depense' => 'investissement', + 'montant_propose' => 1000, + ]); + + $response = $this->actingAs($raf)->patch(route('lignes-budget.arbitrer', $ligne), [ + 'statut_arbitrage' => 'accepte_dsi', + 'montant_arbitre' => 1500, // > montant_propose + ]); + + $response->assertSessionHasErrors('montant_arbitre'); + $this->assertDatabaseHas('ligne_budgets', [ + 'id' => $ligne->id, + 'montant_arbitre' => null, // inchangé + ]); + } + + public function test_arbitrage_accepts_montant_arbitre_equal_to_montant_propose(): void + { + Role::firstOrCreate(['name' => 'raf', 'guard_name' => 'web']); + $service = Service::factory()->create(); + $raf = $this->rafUser($service); + $budget = Budget::create([ + 'service_id' => $service->id, + 'annee' => now()->year, + 'type_budget' => 'agglo', + 'statut' => 'arbitrage_dsi', + ]); + $ligne = LigneBudget::create([ + 'budget_id' => $budget->id, + 'nom' => 'Ligne à arbitrer', + 'type_depense' => 'investissement', + 'montant_propose' => 1000, + ]); + + $response = $this->actingAs($raf)->patch(route('lignes-budget.arbitrer', $ligne), [ + 'statut_arbitrage' => 'accepte_dsi', + 'montant_arbitre' => 1000, // = montant_propose, OK + ]); + + $response->assertRedirect(); + $this->assertDatabaseHas('ligne_budgets', [ + 'id' => $ligne->id, + 'montant_arbitre' => 1000, + 'statut_arbitrage' => 'accepte_dsi', + ]); + } + + public function test_arbitrage_accepts_montant_arbitre_below_montant_propose(): void + { + Role::firstOrCreate(['name' => 'raf', 'guard_name' => 'web']); + $service = Service::factory()->create(); + $raf = $this->rafUser($service); + $budget = Budget::create([ + 'service_id' => $service->id, + 'annee' => now()->year, + 'type_budget' => 'agglo', + 'statut' => 'arbitrage_dsi', + ]); + $ligne = LigneBudget::create([ + 'budget_id' => $budget->id, + 'nom' => 'Ligne à arbitrer', + 'type_depense' => 'investissement', + 'montant_propose' => 1000, + ]); + + $response = $this->actingAs($raf)->patch(route('lignes-budget.arbitrer', $ligne), [ + 'statut_arbitrage' => 'accepte_dsi', + 'montant_arbitre' => 750, + ]); + + $response->assertRedirect(); + $this->assertDatabaseHas('ligne_budgets', [ + 'id' => $ligne->id, + 'montant_arbitre' => 750, + ]); + } +} diff --git a/tests/Feature/BudgetSecurityTest.php b/tests/Feature/BudgetSecurityTest.php new file mode 100644 index 0000000..440f66e --- /dev/null +++ b/tests/Feature/BudgetSecurityTest.php @@ -0,0 +1,354 @@ + $name, 'guard_name' => 'web']); + } + + private function userWithRole(string $role, ?Service $service = null): User + { + $service ??= Service::factory()->create(); + $user = User::factory()->create(['service_id' => $service->id, 'email_verified_at' => now()]); + $user->assignRole($this->createRole($role)); + return $user; + } + + private function makeBudget(Service $service, string $statut = 'preparation'): Budget + { + return Budget::create([ + 'service_id' => $service->id, + 'annee' => 2026, + 'type_budget' => 'agglo', + 'statut' => $statut, + ]); + } + + private function makeLigne(Budget $budget): LigneBudget + { + return LigneBudget::create([ + 'budget_id' => $budget->id, + 'nom' => 'Ligne test', + 'type_depense' => 'investissement', + 'montant_propose' => 1000, + ]); + } + + // ------------------------------------------------------------------------- + // LigneBudgetController::destroy — faille critique + // ------------------------------------------------------------------------- + + public function test_responsable_can_delete_ligne_of_own_service(): void + { + $service = Service::factory()->create(); + $user = $this->userWithRole('responsable', $service); + $budget = $this->makeBudget($service); + $ligne = $this->makeLigne($budget); + + $response = $this->actingAs($user)->delete(route('lignes-budget.destroy', $ligne)); + + $response->assertRedirect(); + $this->assertDatabaseMissing('ligne_budgets', ['id' => $ligne->id]); + } + + public function test_responsable_cannot_delete_ligne_of_another_service(): void + { + $otherService = Service::factory()->create(); + $myService = Service::factory()->create(); + $user = $this->userWithRole('responsable', $myService); + $budget = $this->makeBudget($otherService); + $ligne = $this->makeLigne($budget); + + $response = $this->actingAs($user)->delete(route('lignes-budget.destroy', $ligne)); + + $response->assertForbidden(); + $this->assertDatabaseHas('ligne_budgets', ['id' => $ligne->id]); + } + + public function test_unauthenticated_user_cannot_delete_ligne(): void + { + $service = Service::factory()->create(); + $budget = $this->makeBudget($service); + $ligne = $this->makeLigne($budget); + + $response = $this->delete(route('lignes-budget.destroy', $ligne)); + + $response->assertRedirect(route('login')); + $this->assertDatabaseHas('ligne_budgets', ['id' => $ligne->id]); + } + + public function test_cannot_delete_ligne_when_budget_is_locked(): void + { + $service = Service::factory()->create(); + $user = $this->userWithRole('responsable', $service); + $budget = $this->makeBudget($service, 'valide'); + $ligne = $this->makeLigne($budget); + + $response = $this->actingAs($user)->delete(route('lignes-budget.destroy', $ligne)); + + $response->assertForbidden(); + $this->assertDatabaseHas('ligne_budgets', ['id' => $ligne->id]); + } + + public function test_admin_can_delete_ligne_of_any_service(): void + { + $otherService = Service::factory()->create(); + $admin = $this->userWithRole('admin'); + $budget = $this->makeBudget($otherService); + $ligne = $this->makeLigne($budget); + + $response = $this->actingAs($admin)->delete(route('lignes-budget.destroy', $ligne)); + + $response->assertRedirect(); + $this->assertDatabaseMissing('ligne_budgets', ['id' => $ligne->id]); + } + + // ------------------------------------------------------------------------- + // LigneBudgetController::update — vérification service_id + // ------------------------------------------------------------------------- + + public function test_responsable_can_update_ligne_of_own_service(): void + { + $service = Service::factory()->create(); + $user = $this->userWithRole('responsable', $service); + $budget = $this->makeBudget($service); + $ligne = $this->makeLigne($budget); + + $response = $this->actingAs($user)->put(route('lignes-budget.update', $ligne), [ + 'nom' => 'Ligne modifiée', + 'type_depense' => 'fonctionnement', + 'montant_propose' => 2000, + ]); + + $response->assertRedirect(); + $this->assertDatabaseHas('ligne_budgets', ['id' => $ligne->id, 'nom' => 'Ligne modifiée']); + } + + public function test_responsable_cannot_update_ligne_of_another_service(): void + { + $otherService = Service::factory()->create(); + $myService = Service::factory()->create(); + $user = $this->userWithRole('responsable', $myService); + $budget = $this->makeBudget($otherService); + $ligne = $this->makeLigne($budget); + + $response = $this->actingAs($user)->put(route('lignes-budget.update', $ligne), [ + 'nom' => 'Tentative de modification', + 'type_depense' => 'fonctionnement', + 'montant_propose' => 9999, + ]); + + $response->assertForbidden(); + $this->assertDatabaseHas('ligne_budgets', ['id' => $ligne->id, 'nom' => 'Ligne test']); + } + + public function test_cannot_update_ligne_when_budget_is_locked(): void + { + $service = Service::factory()->create(); + $user = $this->userWithRole('responsable', $service); + $budget = $this->makeBudget($service, 'arbitrage_direction'); + $ligne = $this->makeLigne($budget); + + $response = $this->actingAs($user)->put(route('lignes-budget.update', $ligne), [ + 'nom' => 'Tentative modif budget verrouillé', + 'type_depense' => 'investissement', + 'montant_propose' => 5000, + ]); + + $response->assertForbidden(); + } + + // ------------------------------------------------------------------------- + // LigneBudgetController::store — vérification service_id + // ------------------------------------------------------------------------- + + public function test_responsable_can_add_ligne_to_own_budget(): void + { + $service = Service::factory()->create(); + $user = $this->userWithRole('responsable', $service); + $budget = $this->makeBudget($service); + + $response = $this->actingAs($user)->post(route('lignes-budget.store'), [ + 'budget_id' => $budget->id, + 'nom' => 'Nouvelle ligne', + 'type_depense' => 'investissement', + 'montant_propose' => 500, + ]); + + $response->assertRedirect(); + $this->assertDatabaseHas('ligne_budgets', ['budget_id' => $budget->id, 'nom' => 'Nouvelle ligne']); + } + + public function test_responsable_cannot_add_ligne_to_another_service_budget(): void + { + $otherService = Service::factory()->create(); + $myService = Service::factory()->create(); + $user = $this->userWithRole('responsable', $myService); + $budget = $this->makeBudget($otherService); + + $response = $this->actingAs($user)->post(route('lignes-budget.store'), [ + 'budget_id' => $budget->id, + 'nom' => 'Ligne injectée', + 'type_depense' => 'investissement', + 'montant_propose' => 500, + ]); + + $response->assertForbidden(); + $this->assertDatabaseMissing('ligne_budgets', ['budget_id' => $budget->id]); + } + + // ------------------------------------------------------------------------- + // BudgetController::update — statut réservé aux admin/directeur + // ------------------------------------------------------------------------- + + public function test_directeur_can_change_budget_status(): void + { + $service = Service::factory()->create(); + $user = $this->userWithRole('directeur', $service); + $budget = $this->makeBudget($service, 'preparation'); + + $response = $this->actingAs($user)->put(route('budgets.update', $budget), [ + 'statut' => 'arbitrage_dsi', + ]); + + $response->assertRedirect(); + $this->assertDatabaseHas('budgets', ['id' => $budget->id, 'statut' => 'arbitrage_dsi']); + } + + public function test_responsable_cannot_change_budget_status(): void + { + $service = Service::factory()->create(); + $user = $this->userWithRole('responsable', $service); + $budget = $this->makeBudget($service, 'preparation'); + + $response = $this->actingAs($user)->put(route('budgets.update', $budget), [ + 'statut' => 'valide', + ]); + + $response->assertForbidden(); + $this->assertDatabaseHas('budgets', ['id' => $budget->id, 'statut' => 'preparation']); + } + + public function test_acheteur_cannot_change_budget_status(): void + { + $service = Service::factory()->create(); + $user = $this->userWithRole('acheteur', $service); + $budget = $this->makeBudget($service, 'preparation'); + + $response = $this->actingAs($user)->put(route('budgets.update', $budget), [ + 'statut' => 'valide', + ]); + + $response->assertForbidden(); + } + + // ------------------------------------------------------------------------- + // BudgetController exports — validation des paramètres enum + // ------------------------------------------------------------------------- + + public function test_export_excel_ignores_invalid_type_parameter(): void + { + $admin = $this->userWithRole('admin'); + + // Paramètre type invalide — ne doit pas causer d'erreur ni être utilisé + $response = $this->actingAs($admin)->get(route('budgets.export.excel', [ + 'annee' => 2026, + 'type' => 'INJECTED_VALUE; DROP TABLE budgets;', + ])); + + // Doit retourner le fichier (200) sans planter + $response->assertOk(); + } + + public function test_export_pdf_ignores_invalid_envelope_parameter(): void + { + $admin = $this->userWithRole('admin'); + + $response = $this->actingAs($admin)->get(route('budgets.export.pdf', [ + 'annee' => 2026, + 'envelope' => 'invalid_value', + ])); + + $response->assertOk(); + } + + public function test_export_accepts_valid_type_parameter(): void + { + $admin = $this->userWithRole('admin'); + + $response = $this->actingAs($admin)->get(route('budgets.export.excel', [ + 'annee' => 2026, + 'type' => 'investissement', + ])); + + $response->assertOk(); + } + + public function test_export_accepts_valid_envelope_parameter(): void + { + $admin = $this->userWithRole('admin'); + + $response = $this->actingAs($admin)->get(route('budgets.export.pdf', [ + 'annee' => 2026, + 'envelope' => 'agglo', + ])); + + $response->assertOk(); + } + + // ------------------------------------------------------------------------- + // Arbitrage — réservé aux admin/directeur/raf + // ------------------------------------------------------------------------- + + public function test_raf_can_arbitrer_ligne(): void + { + $service = Service::factory()->create(); + $user = $this->userWithRole('raf', $service); + $budget = $this->makeBudget($service); + $ligne = $this->makeLigne($budget); + + $response = $this->actingAs($user)->patch(route('lignes-budget.arbitrer', $ligne), [ + 'statut_arbitrage' => 'accepte_dsi', + 'montant_arbitre' => 800, + ]); + + $response->assertRedirect(); + $this->assertDatabaseHas('ligne_budgets', [ + 'id' => $ligne->id, + 'statut_arbitrage' => 'accepte_dsi', + 'montant_arbitre' => 800, + ]); + } + + public function test_responsable_cannot_arbitrer_ligne(): void + { + $service = Service::factory()->create(); + $user = $this->userWithRole('responsable', $service); + $budget = $this->makeBudget($service); + $ligne = $this->makeLigne($budget); + + $response = $this->actingAs($user)->patch(route('lignes-budget.arbitrer', $ligne), [ + 'statut_arbitrage' => 'valide_definitif', + 'montant_arbitre' => 1000, + ]); + + $response->assertForbidden(); + $this->assertDatabaseHas('ligne_budgets', [ + 'id' => $ligne->id, + 'statut_arbitrage' => 'brouillon', + ]); + } +}