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:
290
app/Http/Controllers/BudgetController.php
Normal file
290
app/Http/Controllers/BudgetController.php
Normal file
@@ -0,0 +1,290 @@
|
||||
<?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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
98
app/Http/Controllers/LigneBudgetController.php
Normal file
98
app/Http/Controllers/LigneBudgetController.php
Normal file
@@ -0,0 +1,98 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Budget;
|
||||
use App\Models\HistoriqueBudget;
|
||||
use App\Models\LigneBudget;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class LigneBudgetController extends Controller
|
||||
{
|
||||
public function store(Request $request)
|
||||
{
|
||||
$request->validate([
|
||||
'budget_id' => 'required|exists:budgets,id',
|
||||
'nom' => 'required|string|max:255',
|
||||
'description' => 'nullable|string',
|
||||
'type_depense' => 'required|in:investissement,fonctionnement',
|
||||
'montant_propose' => 'required|numeric|min:0',
|
||||
'commune_id' => 'nullable|exists:communes,id',
|
||||
]);
|
||||
|
||||
$budget = Budget::findOrFail($request->budget_id);
|
||||
|
||||
$this->authorize('addLigne', $budget);
|
||||
|
||||
LigneBudget::create($request->only([
|
||||
'budget_id', 'nom', 'description', 'type_depense', 'montant_propose', 'commune_id',
|
||||
]));
|
||||
|
||||
return redirect()->back()->with('success', 'Ligne budgétaire ajoutée.');
|
||||
}
|
||||
|
||||
public function update(Request $request, LigneBudget $lignes_budget)
|
||||
{
|
||||
$this->authorize('updateLigne', $lignes_budget);
|
||||
|
||||
$request->validate([
|
||||
'nom' => 'required|string|max:255',
|
||||
'description' => 'nullable|string',
|
||||
'type_depense' => 'required|in:investissement,fonctionnement',
|
||||
'montant_propose' => 'required|numeric|min:0',
|
||||
'commune_id' => 'nullable|exists:communes,id',
|
||||
]);
|
||||
|
||||
$lignes_budget->update($request->only([
|
||||
'nom', 'description', 'type_depense', 'montant_propose', 'commune_id',
|
||||
]));
|
||||
|
||||
return redirect()->back()->with('success', 'Ligne budgétaire mise à jour.');
|
||||
}
|
||||
|
||||
public function arbitrer(Request $request, LigneBudget $ligneBudget)
|
||||
{
|
||||
$this->authorize('arbitrerLigne', $ligneBudget);
|
||||
|
||||
$request->validate([
|
||||
'statut_arbitrage' => 'required|in:propose,accepte_dsi,accepte_direction,valide_definitif,refuse,reporte',
|
||||
'commentaire' => 'nullable|string|max:1000',
|
||||
'montant_arbitre' => [
|
||||
'nullable',
|
||||
'numeric',
|
||||
'min:0',
|
||||
function ($attribute, $value, $fail) use ($ligneBudget) {
|
||||
if ($value !== null && $value > $ligneBudget->montant_propose) {
|
||||
$fail("Le montant arbitré ({$value} €) ne peut pas dépasser le montant proposé ({$ligneBudget->montant_propose} €).");
|
||||
}
|
||||
},
|
||||
],
|
||||
]);
|
||||
|
||||
HistoriqueBudget::create([
|
||||
'ligne_budget_id' => $ligneBudget->id,
|
||||
'user_id' => $request->user()->id,
|
||||
'ancien_statut' => $ligneBudget->statut_arbitrage,
|
||||
'nouveau_statut' => $request->statut_arbitrage,
|
||||
'ancien_montant_arbitre' => $ligneBudget->montant_arbitre,
|
||||
'nouveau_montant_arbitre'=> $request->montant_arbitre,
|
||||
'commentaire' => $request->commentaire,
|
||||
]);
|
||||
|
||||
$ligneBudget->update([
|
||||
'statut_arbitrage' => $request->statut_arbitrage,
|
||||
'montant_arbitre' => $request->montant_arbitre,
|
||||
]);
|
||||
|
||||
return redirect()->back()->with('success', 'Arbitrage enregistré.');
|
||||
}
|
||||
|
||||
public function destroy(LigneBudget $lignes_budget)
|
||||
{
|
||||
$this->authorize('deleteLigne', $lignes_budget);
|
||||
|
||||
$lignes_budget->delete();
|
||||
|
||||
return redirect()->back()->with('success', 'Ligne budgétaire supprimée.');
|
||||
}
|
||||
}
|
||||
60
app/Models/Budget.php
Normal file
60
app/Models/Budget.php
Normal 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;
|
||||
}
|
||||
}
|
||||
34
app/Models/HistoriqueBudget.php
Normal file
34
app/Models/HistoriqueBudget.php
Normal 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);
|
||||
}
|
||||
}
|
||||
85
app/Models/LigneBudget.php
Normal file
85
app/Models/LigneBudget.php
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
110
app/Policies/BudgetPolicy.php
Normal file
110
app/Policies/BudgetPolicy.php
Normal file
@@ -0,0 +1,110 @@
|
||||
<?php
|
||||
|
||||
namespace App\Policies;
|
||||
|
||||
use App\Models\Budget;
|
||||
use App\Models\LigneBudget;
|
||||
use App\Models\User;
|
||||
|
||||
class BudgetPolicy
|
||||
{
|
||||
/**
|
||||
* Tout utilisateur authentifié peut voir la liste des budgets.
|
||||
* Le filtrage par service est géré dans le contrôleur.
|
||||
*/
|
||||
public function viewAny(User $user): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Un utilisateur peut voir un budget s'il appartient à son service,
|
||||
* ou s'il est admin/directeur/raf.
|
||||
*/
|
||||
public function view(User $user, Budget $budget): bool
|
||||
{
|
||||
return $user->hasRole(['admin', 'directeur', 'raf'])
|
||||
|| $budget->service_id === $user->service_id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Seuls les admin et directeur peuvent créer des budgets.
|
||||
*/
|
||||
public function create(User $user): bool
|
||||
{
|
||||
return $user->hasRole(['admin', 'directeur', 'raf']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Seuls les admin et directeur peuvent changer le statut d'un budget.
|
||||
*/
|
||||
public function update(User $user, Budget $budget): bool
|
||||
{
|
||||
return $user->hasRole(['admin', 'directeur']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Seuls les admin peuvent supprimer un budget.
|
||||
*/
|
||||
public function delete(User $user, Budget $budget): bool
|
||||
{
|
||||
return $user->hasRole('admin');
|
||||
}
|
||||
|
||||
/**
|
||||
* Un utilisateur peut ajouter une ligne budgétaire sur un budget
|
||||
* si le budget lui appartient (son service) ou s'il est admin/directeur/raf,
|
||||
* et si le budget n'est pas verrouillé.
|
||||
*/
|
||||
public function addLigne(User $user, Budget $budget): bool
|
||||
{
|
||||
if (in_array($budget->statut, ['arbitrage_direction', 'valide', 'cloture'])) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $user->hasRole(['admin', 'directeur', 'raf'])
|
||||
|| $budget->service_id === $user->service_id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Un utilisateur peut modifier une ligne budgétaire si :
|
||||
* - le budget parent lui appartient (son service) ou il est admin/directeur/raf
|
||||
* - le budget n'est pas verrouillé (arbitrage_direction, valide, cloture)
|
||||
*/
|
||||
public function updateLigne(User $user, LigneBudget $ligneBudget): bool
|
||||
{
|
||||
$budget = $ligneBudget->budget;
|
||||
|
||||
if (!$budget || in_array($budget->statut, ['arbitrage_direction', 'valide', 'cloture'])) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $user->hasRole(['admin', 'directeur', 'raf'])
|
||||
|| $budget->service_id === $user->service_id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Un utilisateur peut supprimer une ligne budgétaire si :
|
||||
* - le budget parent lui appartient (son service) ou il est admin/directeur/raf
|
||||
* - le budget n'est pas verrouillé
|
||||
*/
|
||||
public function deleteLigne(User $user, LigneBudget $ligneBudget): bool
|
||||
{
|
||||
$budget = $ligneBudget->budget;
|
||||
|
||||
if (!$budget || in_array($budget->statut, ['arbitrage_direction', 'valide', 'cloture'])) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $user->hasRole(['admin', 'directeur', 'raf'])
|
||||
|| $budget->service_id === $user->service_id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Seuls les admin, directeur et raf peuvent arbitrer les lignes budgétaires.
|
||||
*/
|
||||
public function arbitrerLigne(User $user, LigneBudget $ligneBudget): bool
|
||||
{
|
||||
return $user->hasRole(['admin', 'directeur', 'raf']);
|
||||
}
|
||||
}
|
||||
@@ -2,11 +2,14 @@
|
||||
|
||||
namespace App\Providers;
|
||||
|
||||
use App\Models\Budget;
|
||||
use App\Models\Categorie;
|
||||
use App\Models\Commande;
|
||||
use App\Models\Fournisseur;
|
||||
use App\Models\LigneBudget;
|
||||
use App\Models\PieceJointe;
|
||||
use App\Models\User;
|
||||
use App\Policies\BudgetPolicy;
|
||||
use App\Policies\CommandePolicy;
|
||||
use App\Policies\FournisseurPolicy;
|
||||
use App\Policies\PieceJointePolicy;
|
||||
@@ -18,6 +21,8 @@ use Illuminate\Support\Facades\Vite;
|
||||
class AppServiceProvider extends ServiceProvider
|
||||
{
|
||||
protected $policies = [
|
||||
Budget::class => BudgetPolicy::class,
|
||||
LigneBudget::class => BudgetPolicy::class,
|
||||
Commande::class => CommandePolicy::class,
|
||||
Fournisseur::class => FournisseurPolicy::class,
|
||||
PieceJointe::class => PieceJointePolicy::class,
|
||||
|
||||
Reference in New Issue
Block a user