feat: module budgets complet avec sécurité, performance et métier

## 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 <noreply@anthropic.com>
This commit is contained in:
jeremy bayse
2026-04-11 20:20:05 +02:00
parent b28c56c94c
commit 0ad77de412
31 changed files with 3574 additions and 37 deletions

View File

@@ -0,0 +1,98 @@
<?php
namespace App\Http\Controllers;
use App\Models\Budget;
use App\Models\HistoriqueBudget;
use App\Models\LigneBudget;
use Illuminate\Http\Request;
class LigneBudgetController extends Controller
{
public function store(Request $request)
{
$request->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.');
}
}