Files
dsi-commander/app/Http/Controllers/BudgetController.php
jeremy bayse 0ad77de412 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>
2026-04-11 20:20:05 +02:00

291 lines
12 KiB
PHP

<?php
namespace App\Http\Controllers;
use App\Models\Budget;
use App\Models\Service;
use App\Models\LigneBudget;
use App\Exports\BudgetExecutionExport;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Maatwebsite\Excel\Facades\Excel;
use Barryvdh\DomPDF\Facade\Pdf;
class BudgetController extends Controller
{
public function index(Request $request)
{
$user = $request->user();
$query = Budget::with(['service', 'lignes', 'commune']);
// Le responsable ne voit que les budgets de son service
if (!$user->hasRole(['admin', 'directeur', 'raf'])) {
$query->where('service_id', $user->service_id);
}
$annee = $request->get('annee', now()->year);
$query->where('annee', $annee);
$budgets = $query->orderBy('annee', 'desc')->get()->map(function ($budget) {
return [
'id' => $budget->id,
'service' => $budget->service->nom,
'commune' => $budget->commune?->nom,
'annee' => $budget->annee,
'type_budget' => $budget->type_budget,
'statut' => $budget->statut,
'total_invest_propose' => $budget->totalPropose('investissement'),
'total_fonct_propose' => $budget->totalPropose('fonctionnement'),
'total_invest_arbitre' => $budget->totalArbitre('investissement'),
'total_fonct_arbitre' => $budget->totalArbitre('fonctionnement'),
];
});
return Inertia::render('Budgets/Index', [
'budgets' => $budgets,
'services' => Service::all(),
'communes' => \App\Models\Commune::orderBy('nom')->get(),
'filters' => ['annee' => $annee]
]);
}
public function store(Request $request)
{
$request->validate([
'service_id' => 'required|exists:services,id',
'annee' => 'required|integer',
'type_budget' => 'required|in:agglo,mutualise',
]);
Budget::create($request->all());
return redirect()->back()->with('success', 'Enveloppe budgétaire créée avec succès.');
}
public function show(Budget $budget, Request $request)
{
$user = $request->user();
if (!$user->hasRole(['admin', 'directeur', 'raf']) && $budget->service_id !== $user->service_id) {
abort(403);
}
$budget->load(['service', 'lignes.commandes', 'lignes.historique.user']);
$totals = [
'invest_propose' => $budget->totalPropose('investissement'),
'invest_arbitre' => $budget->totalArbitre('investissement'),
'fonct_propose' => $budget->totalPropose('fonctionnement'),
'fonct_arbitre' => $budget->totalArbitre('fonctionnement'),
];
$lignes = $budget->lignes->map(function ($ligne) {
return array_merge($ligne->toArray(), [
'historique' => $ligne->historique->map(fn($h) => [
'id' => $h->id,
'ancien_statut' => $h->ancien_statut,
'nouveau_statut' => $h->nouveau_statut,
'ancien_montant_arbitre' => $h->ancien_montant_arbitre,
'nouveau_montant_arbitre'=> $h->nouveau_montant_arbitre,
'commentaire' => $h->commentaire,
'user' => $h->user->name,
'created_at' => $h->created_at->format('d/m/Y H:i'),
]),
]);
});
return Inertia::render('Budgets/Show', [
'budget' => $budget,
'lignes' => $lignes,
'totals' => $totals,
'communes'=> \App\Models\Commune::orderBy('nom')->get(),
]);
}
public function update(Request $request, Budget $budget)
{
$this->authorize('update', $budget);
$request->validate([
'statut' => 'required|in:preparation,arbitrage_dsi,arbitrage_direction,valide,cloture',
]);
$budget->update(['statut' => $request->statut]);
return redirect()->back()->with('success', 'Statut du budget mis à jour.');
}
public function execution(Request $request)
{
$user = $request->user();
$annee = $request->get('annee', now()->year);
$query = \App\Models\LigneBudget::with(['budget.service', 'commandes.commune'])
->whereHas('budget', function($q) use ($annee) {
$q->where('annee', $annee);
});
// Filtrage par service si pas admin/directeur/raf
if (!$user->hasRole(['admin', 'directeur', 'raf'])) {
$query->whereHas('budget', function($q) use ($user) {
$q->where('service_id', $user->service_id);
});
}
$statutsConsommes = ['validee', 'commandee', 'partiellement_recue', 'recue_complete', 'cloturee'];
$statutsEngages = ['brouillon', 'en_attente_validation'];
$lignes = $query->get()->map(function($lb) use ($statutsConsommes, $statutsEngages) {
// Toutes les commandes déjà chargées en mémoire — zéro requête supplémentaire
$commandes = $lb->commandes;
$consomme = (float) $commandes->whereIn('statut', $statutsConsommes)->sum('montant_ttc');
$engage = (float) $commandes->whereIn('statut', $statutsEngages)->sum('montant_ttc');
$totalEngage = $consomme + $engage;
// Ventilation par commune — calculée en mémoire
$ventilation = $commandes
->whereNotIn('statut', ['annulee'])
->groupBy('commune_id')
->map(fn($group, $communeId) => [
'commune' => $group->first()->commune?->nom ?? 'Non défini',
'total' => (float) $group->sum('montant_ttc'),
])
->values();
// Liste détaillée des commandes pour affichage
$commandesList = $commandes
->whereNotIn('statut', ['annulee'])
->map(fn($c) => [
'id' => $c->id,
'reference' => $c->numero_commande ?? 'CMD-'.$c->id,
'nom' => $c->objet,
'commune' => $c->commune?->nom,
'montant_ttc'=> (float) $c->montant_ttc,
'statut' => $c->statut,
])
->values();
return [
'id' => $lb->id,
'nom' => $lb->nom,
'type_depense' => $lb->type_depense,
'type_budget' => $lb->budget->type_budget,
'service' => $lb->budget->service->nom,
'annee' => $lb->budget->annee,
'montant_arbitre' => (float) $lb->montant_arbitre,
'consomme' => $consomme,
'engage' => $engage,
'total_cumule' => $totalEngage,
'reste' => (float) $lb->montant_arbitre - $totalEngage,
'ventilation' => $ventilation,
'commandes' => $commandesList
];
});
return Inertia::render('Budgets/Execution', [
'lignes' => $lignes,
'services' => \App\Models\Service::orderBy('nom')->get(),
'filters' => [
'annee' => $annee
]
]);
}
private function getExportFilename(string $base, int $annee, array $filters, string $ext): string
{
$name = $base . '-' . $annee;
if ($filters['type']) $name .= '-' . $filters['type'];
if ($filters['envelope']) $name .= '-' . $filters['envelope'];
if ($filters['service']) $name .= '-' . \Str::slug($filters['service']);
return $name . '.' . $ext;
}
/**
* Valide et extrait les filtres d'export depuis la requête.
* Les valeurs non reconnues sont ignorées pour éviter toute injection de paramètres.
*/
private function resolveExportFilters(Request $request): array
{
$type = $request->get('type');
$envelope = $request->get('envelope');
return [
'type' => in_array($type, ['investissement', 'fonctionnement']) ? $type : null,
'envelope' => in_array($envelope, ['agglo', 'mutualise']) ? $envelope : null,
'service' => $request->get('service') !== 'all' ? $request->get('service') : null,
];
}
public function exportExcel(Request $request)
{
$annee = (int) $request->get('annee', now()->year);
$filters = $this->resolveExportFilters($request);
$filename = $this->getExportFilename('budget-execution', $annee, $filters, 'xlsx');
return Excel::download(new BudgetExecutionExport($request->user(), $annee, $filters), $filename);
}
public function exportOds(Request $request)
{
$annee = (int) $request->get('annee', now()->year);
$filters = $this->resolveExportFilters($request);
$filename = $this->getExportFilename('budget-execution', $annee, $filters, 'ods');
return Excel::download(new BudgetExecutionExport($request->user(), $annee, $filters), $filename, \Maatwebsite\Excel\Excel::ODS);
}
public function exportPdf(Request $request)
{
$user = $request->user();
$annee = (int) $request->get('annee', now()->year);
$filters = $this->resolveExportFilters($request);
$query = LigneBudget::with(['budget.service', 'commandes'])
->whereHas('budget', function($q) use ($annee) {
$q->where('annee', $annee);
});
if (!$user->hasRole(['admin', 'directeur', 'raf'])) {
$query->whereHas('budget', function($q) use ($user) {
$q->where('service_id', $user->service_id);
});
}
if ($filters['type']) {
$query->where('type_depense', $filters['type']);
}
if ($filters['envelope']) {
$query->whereHas('budget', fn($q) => $q->where('type_budget', $filters['envelope']));
}
if ($filters['service']) {
$query->whereHas('budget.service', fn($q) => $q->where('nom', $filters['service']));
}
$statutsConsommes = ['validee', 'commandee', 'partiellement_recue', 'recue_complete', 'cloturee'];
$statutsEngages = ['brouillon', 'en_attente_validation'];
$lignes = $query->get()->map(function($lb) use ($statutsConsommes, $statutsEngages) {
$commandes = $lb->commandes;
$lb->consomme = (float) $commandes->whereIn('statut', $statutsConsommes)->sum('montant_ttc');
$lb->engage = (float) $commandes->whereIn('statut', $statutsEngages)->sum('montant_ttc');
$lb->total_cumule = $lb->consomme + $lb->engage;
$lb->reste = (float) ($lb->montant_arbitre ?? 0) - $lb->total_cumule;
return $lb;
});
$pdf = Pdf::loadView('exports.budgets_pdf', [
'lignes' => $lignes,
'annee' => $annee,
'user' => $user,
'type' => $filters['type'] ? ucfirst($filters['type']) : 'Tous types',
'envelope' => $filters['envelope'] ? ucfirst($filters['envelope']) : 'Toutes enveloppes',
'service' => !$user->hasRole(['admin', 'directeur', 'raf'])
? ($user->service->nom ?? 'Inconnu')
: ($filters['service'] ?? 'Tous les services'),
])->setPaper('a4', 'landscape');
$filename = $this->getExportFilename('rapport-budgetaire', $annee, $filters, 'pdf');
return $pdf->download($filename);
}
}