## 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>
341 lines
14 KiB
PHP
341 lines
14 KiB
PHP
<?php
|
|
|
|
namespace App\Http\Controllers;
|
|
|
|
use App\Models\Article;
|
|
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;
|
|
use Inertia\Inertia;
|
|
use Inertia\Response;
|
|
|
|
class CommandeController extends Controller
|
|
{
|
|
public function index(Request $request): Response
|
|
{
|
|
$query = Commande::with(['service', 'fournisseur', 'demandeur'])
|
|
->when($request->service_id, fn ($q) => $q->parService($request->service_id))
|
|
->when($request->fournisseur_id, fn ($q) => $q->parFournisseur($request->fournisseur_id))
|
|
->when($request->commune_id, fn ($q) => $q->where('commune_id', $request->commune_id))
|
|
->when($request->statut, fn ($q) => $q->parStatut($request->statut))
|
|
->when($request->priorite, fn ($q) => $q->where('priorite', $request->priorite))
|
|
->when($request->date_from, fn ($q) => $q->whereDate('date_demande', '>=', $request->date_from))
|
|
->when($request->date_to, fn ($q) => $q->whereDate('date_demande', '<=', $request->date_to))
|
|
->when($request->search, function ($q) use ($request) {
|
|
$q->where(function ($sub) use ($request) {
|
|
$sub->where('numero_commande', 'like', "%{$request->search}%")
|
|
->orWhere('objet', 'like', "%{$request->search}%")
|
|
->orWhereHas('fournisseur', fn ($f) => $f->where('nom', 'like', "%{$request->search}%"));
|
|
});
|
|
});
|
|
|
|
$commandes = $query->latest()->paginate(20)->withQueryString();
|
|
|
|
return Inertia::render('Commandes/Index', [
|
|
'commandes' => $commandes,
|
|
'services' => Service::all(),
|
|
'fournisseurs' => Fournisseur::active()->orderBy('nom')->get(),
|
|
'communes' => \App\Models\Commune::orderBy('nom')->get(),
|
|
'filters' => $request->only(['search', 'service_id', 'fournisseur_id', 'statut', 'priorite', 'date_from', 'date_to']),
|
|
]);
|
|
}
|
|
|
|
public function create(): Response
|
|
{
|
|
$this->authorize('create', Commande::class);
|
|
|
|
return Inertia::render('Commandes/Create', [
|
|
'services' => Service::all(),
|
|
'fournisseurs' => Fournisseur::active()->orderBy('nom')->get(),
|
|
'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(),
|
|
]);
|
|
}
|
|
|
|
public function store(Request $request): RedirectResponse
|
|
{
|
|
$this->authorize('create', Commande::class);
|
|
|
|
$validated = $request->validate([
|
|
'service_id' => 'required|exists:services,id',
|
|
'fournisseur_id' => 'nullable|exists:fournisseurs,id',
|
|
'commune_id' => 'nullable|exists:communes,id',
|
|
'objet' => 'required|string|max:255',
|
|
'description' => 'nullable|string',
|
|
'justification' => 'nullable|string',
|
|
'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',
|
|
'notes' => 'nullable|string',
|
|
'notes_fournisseur' => 'nullable|string',
|
|
'lignes' => 'array',
|
|
'lignes.*.designation' => 'required|string|max:255',
|
|
'lignes.*.reference' => 'nullable|string|max:100',
|
|
'lignes.*.quantite' => 'required|numeric|min:0.001',
|
|
'lignes.*.unite' => 'nullable|string|max:30',
|
|
'lignes.*.prix_unitaire_ht' => 'nullable|numeric|min:0',
|
|
'lignes.*.taux_tva' => 'nullable|numeric|min:0|max:100',
|
|
'lignes.*.categorie_id' => 'nullable|exists:categories,id',
|
|
'lignes.*.article_id' => 'nullable|exists:articles,id',
|
|
'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,
|
|
'numero_commande' => Commande::genererNumero(),
|
|
'user_id' => $request->user()->id,
|
|
'statut' => 'brouillon',
|
|
]);
|
|
|
|
foreach ($validated['lignes'] ?? [] as $index => $ligne) {
|
|
$commande->lignes()->create(array_merge($ligne, ['ordre' => $index]));
|
|
}
|
|
});
|
|
|
|
return redirect()->route('commandes.index')
|
|
->with('success', 'Commande créée avec succès.');
|
|
}
|
|
|
|
public function show(Commande $commande): Response
|
|
{
|
|
$this->authorize('view', $commande);
|
|
|
|
$commande->load([
|
|
'service', 'fournisseur', 'demandeur', 'validateur', 'acheteur', 'commune',
|
|
'lignes.categorie',
|
|
'historique.user',
|
|
'piecesJointes.user',
|
|
'assets',
|
|
'ligneBudget.budget.service'
|
|
]);
|
|
|
|
$transitionsDisponibles = collect(Commande::STATUT_TRANSITIONS[$commande->statut] ?? [])
|
|
->filter(fn ($statut) => request()->user()->hasRole('admin') || $this->userCanTransition(request()->user(), $commande, $statut))
|
|
->values();
|
|
|
|
return Inertia::render('Commandes/Show', [
|
|
'commande' => $commande,
|
|
'transitionsDisponibles' => $transitionsDisponibles,
|
|
]);
|
|
}
|
|
|
|
public function edit(Commande $commande): Response
|
|
{
|
|
$this->authorize('update', $commande);
|
|
|
|
$commande->load('lignes.categorie');
|
|
|
|
return Inertia::render('Commandes/Edit', [
|
|
'commande' => $commande,
|
|
'services' => Service::all(),
|
|
'fournisseurs' => Fournisseur::active()->orderBy('nom')->get(),
|
|
'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(),
|
|
]);
|
|
}
|
|
|
|
public function update(Request $request, Commande $commande): RedirectResponse
|
|
{
|
|
$this->authorize('update', $commande);
|
|
|
|
$validated = $request->validate([
|
|
'service_id' => 'required|exists:services,id',
|
|
'fournisseur_id' => 'nullable|exists:fournisseurs,id',
|
|
'commune_id' => 'nullable|exists:communes,id',
|
|
'objet' => 'required|string|max:255',
|
|
'description' => 'nullable|string',
|
|
'justification' => 'nullable|string',
|
|
'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',
|
|
'notes' => 'nullable|string',
|
|
'notes_fournisseur' => 'nullable|string',
|
|
'lignes' => 'array',
|
|
'lignes.*.id' => 'nullable|integer',
|
|
'lignes.*.designation' => 'required|string|max:255',
|
|
'lignes.*.reference' => 'nullable|string|max:100',
|
|
'lignes.*.quantite' => 'required|numeric|min:0.001',
|
|
'lignes.*.quantite_recue' => 'nullable|numeric|min:0',
|
|
'lignes.*.unite' => 'nullable|string|max:30',
|
|
'lignes.*.prix_unitaire_ht' => 'nullable|numeric|min:0',
|
|
'lignes.*.taux_tva' => 'nullable|numeric|min:0|max:100',
|
|
'lignes.*.categorie_id' => 'nullable|exists:categories,id',
|
|
'lignes.*.article_id' => 'nullable|exists:articles,id',
|
|
'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);
|
|
|
|
$lignesData = $validated['lignes'] ?? [];
|
|
$lignesIds = collect($lignesData)->pluck('id')->filter()->all();
|
|
|
|
// Supprimer les lignes retirées
|
|
$commande->lignes()->whereNotIn('id', $lignesIds)->delete();
|
|
|
|
foreach ($lignesData as $index => $ligneData) {
|
|
$commande->lignes()->updateOrCreate(
|
|
['id' => $ligneData['id'] ?? null],
|
|
array_merge($ligneData, ['ordre' => $index, 'commande_id' => $commande->id])
|
|
);
|
|
}
|
|
});
|
|
|
|
return redirect()->route('commandes.show', $commande)
|
|
->with('success', 'Commande mise à jour.');
|
|
}
|
|
|
|
public function destroy(Commande $commande): RedirectResponse
|
|
{
|
|
$this->authorize('delete', $commande);
|
|
|
|
$commande->delete();
|
|
|
|
return redirect()->route('commandes.index')
|
|
->with('success', 'Commande supprimée.');
|
|
}
|
|
|
|
public function transition(Request $request, Commande $commande): RedirectResponse
|
|
{
|
|
$validated = $request->validate([
|
|
'statut' => 'required|string',
|
|
'commentaire' => 'nullable|string|max:1000',
|
|
]);
|
|
|
|
$this->authorize('transition', [$commande, $validated['statut']]);
|
|
|
|
$ok = $commande->transitionnerVers($validated['statut'], $request->user(), $validated['commentaire'] ?? null);
|
|
|
|
if (!$ok) {
|
|
return back()->with('error', 'Transition non autorisée.');
|
|
}
|
|
|
|
return redirect()->route('commandes.show', $commande)
|
|
->with('success', 'Statut mis à jour : ' . Commande::STATUTS_LABELS[$validated['statut']]);
|
|
}
|
|
|
|
private function userCanTransition($user, Commande $commande, string $statut): bool
|
|
{
|
|
try {
|
|
$this->authorize('transition', [$commande, $statut]);
|
|
return true;
|
|
} catch (\Exception) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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);
|
|
|
|
$commande->load([
|
|
'service', 'fournisseur', 'demandeur', 'validateur', 'acheteur', 'commune',
|
|
'lignes.categorie',
|
|
]);
|
|
|
|
$pdf = \Barryvdh\DomPDF\Facade\Pdf::loadView('pdf.commande', [
|
|
'commande' => $commande,
|
|
]);
|
|
|
|
return $pdf->stream('commande-' . $commande->numero_commande . '.pdf');
|
|
}
|
|
}
|