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,24 @@
<?php
namespace Database\Factories;
use App\Models\Service;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends Factory<Service>
*/
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',
];
}
}

View File

@@ -0,0 +1,28 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('budgets', function (Blueprint $table) {
$table->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');
}
};

View File

@@ -0,0 +1,28 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('ligne_budgets', function (Blueprint $table) {
$table->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');
}
};

View File

@@ -0,0 +1,23 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('commandes', function (Blueprint $table) {
$table->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');
});
}
};

View File

@@ -0,0 +1,29 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('budgets', function (Blueprint $table) {
$table->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');
});
}
};

View File

@@ -0,0 +1,29 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('ligne_budgets', function (Blueprint $table) {
$table->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');
});
}
};

View File

@@ -0,0 +1,25 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
use Spatie\Permission\Models\Role;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Role::firstOrCreate(['name' => 'raf', 'guard_name' => 'web']);
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Role::where('name', 'raf')->delete();
}
};

View File

@@ -0,0 +1,36 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('historique_budgets', function (Blueprint $table) {
$table->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');
}
};