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:
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user