## 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>
256 lines
10 KiB
PHP
256 lines
10 KiB
PHP
<?php
|
|
|
|
namespace Tests\Feature;
|
|
|
|
use App\Models\Budget;
|
|
use App\Models\HistoriqueBudget;
|
|
use App\Models\LigneBudget;
|
|
use App\Models\Service;
|
|
use App\Models\User;
|
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
|
use Spatie\Permission\Models\Role;
|
|
use Tests\TestCase;
|
|
|
|
class BudgetMetierTest extends TestCase
|
|
{
|
|
use RefreshDatabase;
|
|
|
|
private function createRole(string $name): Role
|
|
{
|
|
return Role::firstOrCreate(['name' => $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');
|
|
}
|
|
}
|