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

60
app/Models/Budget.php Normal file
View File

@@ -0,0 +1,60 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
class Budget extends Model
{
use HasFactory;
protected $fillable = [
'service_id',
'commune_id',
'annee',
'type_budget',
'statut',
];
public function service(): BelongsTo
{
return $this->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;
}
}

View File

@@ -0,0 +1,34 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class HistoriqueBudget extends Model
{
protected $fillable = [
'ligne_budget_id',
'user_id',
'ancien_statut',
'nouveau_statut',
'ancien_montant_arbitre',
'nouveau_montant_arbitre',
'commentaire',
];
protected $casts = [
'ancien_montant_arbitre' => '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);
}
}

View File

@@ -0,0 +1,85 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
class LigneBudget extends Model
{
use HasFactory;
protected $fillable = [
'budget_id',
'commune_id',
'nom',
'description',
'type_depense',
'montant_propose',
'montant_arbitre',
'statut_arbitrage',
];
protected $appends = [
'montant_consomme',
'montant_engage',
'montant_disponible',
];
protected $casts = [
'montant_propose' => '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);
}
}

View File

@@ -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