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:
jeremy bayse
2026-04-11 20:20:05 +02:00
parent b28c56c94c
commit 0ad77de412
31 changed files with 3574 additions and 37 deletions

View File

@@ -1,6 +1,8 @@
<script setup>
import { computed, watch } from 'vue'
import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout.vue'
import LignesCommandeForm from '@/Components/Commandes/LignesCommandeForm.vue'
import SearchableSelect from '@/Components/SearchableSelect.vue'
import { Head, Link, useForm } from '@inertiajs/vue3'
const props = defineProps({
@@ -9,6 +11,7 @@ const props = defineProps({
communes: Array,
categories: Array,
articles: Array,
lignesBudget: Array,
})
const form = useForm({
@@ -21,6 +24,7 @@ const form = useForm({
priorite: 'normale',
reference_fournisseur: '',
imputation_budgetaire: '',
ligne_budget_id: '',
date_demande: new Date().toISOString().slice(0, 10),
date_souhaitee: '',
date_livraison_prevue: '',
@@ -29,6 +33,49 @@ const form = useForm({
lignes: [],
})
const ligneBudgetOptions = computed(() => {
const targetAnnee = form.date_demande
? new Date(form.date_demande).getFullYear()
: new Date().getFullYear()
const targetTypeBudget = (!form.commune_id || String(form.commune_id) === '1')
? 'agglo'
: 'mutualise'
return props.lignesBudget?.filter(lb => {
// Toujours conserver la ligne actuellement sélectionnée pour l'affichage
if (form.ligne_budget_id && lb.id === form.ligne_budget_id) return true
if (form.service_id && lb.budget?.service_id !== form.service_id) return false
if (lb.budget?.annee !== targetAnnee) return false
if (lb.budget?.type_budget !== targetTypeBudget) return false
return true
}).map(lb => ({
value: lb.id,
label: `[${lb.budget?.annee} - ${lb.budget?.service?.nom}] ${lb.nom} (Dispo: ${new Intl.NumberFormat('fr-FR', { style: 'currency', currency: 'EUR' }).format(lb.montant_disponible)})`
})) || []
})
watch([() => form.service_id, () => form.commune_id, () => form.date_demande], ([newService, newCommune, newDate]) => {
if (form.ligne_budget_id) {
const selectedLb = props.lignesBudget?.find(lb => lb.id === form.ligne_budget_id)
if (selectedLb) {
const targetAnnee = newDate ? new Date(newDate).getFullYear() : new Date().getFullYear()
const targetTypeBudget = (!newCommune || String(newCommune) === '1') ? 'agglo' : 'mutualise'
let isValid = true
if (newService && selectedLb.budget?.service_id !== newService) isValid = false
if (selectedLb.budget?.annee !== targetAnnee) isValid = false
if (selectedLb.budget?.type_budget !== targetTypeBudget) isValid = false
if (!isValid) {
form.ligne_budget_id = ''
}
}
}
})
function submit() {
form.post(route('commandes.store'))
}
@@ -117,11 +164,22 @@ function submit() {
</div>
<div>
<label class="block text-sm font-medium text-gray-700">Imputation budgétaire</label>
<label class="block text-sm font-medium text-gray-700">Imputation budgétaire (texte)</label>
<input v-model="form.imputation_budgetaire" type="text"
class="mt-1 block w-full rounded-lg border border-gray-300 px-3 py-2 text-sm shadow-sm focus:border-blue-500 focus:outline-none" />
</div>
<div class="sm:col-span-2">
<label class="block text-sm font-medium text-gray-700">Ligne Budgétaire (Module Budget)</label>
<SearchableSelect
v-model="form.ligne_budget_id"
:options="ligneBudgetOptions"
placeholder="Rechercher un projet / enveloppe..."
:class="form.errors.ligne_budget_id ? 'ring-1 ring-red-500 rounded-lg' : ''"
/>
<p v-if="form.errors.ligne_budget_id" class="mt-1 text-xs text-red-600">{{ form.errors.ligne_budget_id }}</p>
</div>
<div class="sm:col-span-2">
<label class="block text-sm font-medium text-gray-700">Description</label>
<textarea v-model="form.description" rows="2"

View File

@@ -1,6 +1,8 @@
<script setup>
import { computed, watch } from 'vue'
import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout.vue'
import LignesCommandeForm from '@/Components/Commandes/LignesCommandeForm.vue'
import SearchableSelect from '@/Components/SearchableSelect.vue'
import StatutBadge from '@/Components/Commandes/StatutBadge.vue'
import { Head, Link, useForm } from '@inertiajs/vue3'
@@ -11,6 +13,7 @@ const props = defineProps({
communes: Array,
categories: Array,
articles: Array,
lignesBudget: Array,
})
const form = useForm({
@@ -20,10 +23,11 @@ const form = useForm({
objet: props.commande.objet,
description: props.commande.description ?? '',
justification: props.commande.justification ?? '',
priorite: props.commande.priorite,
reference_fournisseur: props.commande.reference_fournisseur ?? '',
imputation_budgetaire: props.commande.imputation_budgetaire ?? '',
date_demande: props.commande.date_demande,
priorite: props.commande.priorite || 'normale',
reference_fournisseur: props.commande.reference_fournisseur || '',
imputation_budgetaire: props.commande.imputation_budgetaire || '',
ligne_budget_id: props.commande.ligne_budget_id || '',
date_demande: props.commande.date_demande ? props.commande.date_demande.substring(0, 10) : '',
date_souhaitee: props.commande.date_souhaitee ?? '',
date_livraison_prevue: props.commande.date_livraison_prevue ?? '',
notes: props.commande.notes ?? '',
@@ -31,6 +35,49 @@ const form = useForm({
lignes: props.commande.lignes ?? [],
})
const ligneBudgetOptions = computed(() => {
const targetAnnee = form.date_demande
? new Date(form.date_demande).getFullYear()
: new Date().getFullYear()
const targetTypeBudget = (!form.commune_id || String(form.commune_id) === '1')
? 'agglo'
: 'mutualise'
return props.lignesBudget?.filter(lb => {
// Toujours conserver la ligne actuellement sélectionnée pour l'affichage
if (form.ligne_budget_id && lb.id === form.ligne_budget_id) return true
if (form.service_id && lb.budget?.service_id !== form.service_id) return false
if (lb.budget?.annee !== targetAnnee) return false
if (lb.budget?.type_budget !== targetTypeBudget) return false
return true
}).map(lb => ({
value: lb.id,
label: `[${lb.budget?.annee} - ${lb.budget?.service?.nom}] ${lb.nom} (Dispo: ${new Intl.NumberFormat('fr-FR', { style: 'currency', currency: 'EUR' }).format(lb.montant_disponible)})`
})) || []
})
watch([() => form.service_id, () => form.commune_id, () => form.date_demande], ([newService, newCommune, newDate]) => {
if (form.ligne_budget_id) {
const selectedLb = props.lignesBudget?.find(lb => lb.id === form.ligne_budget_id)
if (selectedLb) {
const targetAnnee = newDate ? new Date(newDate).getFullYear() : new Date().getFullYear()
const targetTypeBudget = (!newCommune || String(newCommune) === '1') ? 'agglo' : 'mutualise'
let isValid = true
if (newService && selectedLb.budget?.service_id !== newService) isValid = false
if (selectedLb.budget?.annee !== targetAnnee) isValid = false
if (selectedLb.budget?.type_budget !== targetTypeBudget) isValid = false
if (!isValid) {
form.ligne_budget_id = ''
}
}
}
})
function submit() {
form.put(route('commandes.update', props.commande.id))
}
@@ -111,10 +158,21 @@ const showReceived = ['commandee','partiellement_recue','recue_complete'].includ
class="mt-1 block w-full rounded-lg border border-gray-300 px-3 py-2 text-sm shadow-sm focus:border-blue-500 focus:outline-none" />
</div>
<div>
<label class="block text-sm font-medium text-gray-700">Imputation budgétaire</label>
<label class="block text-sm font-medium text-gray-700">Imputation budgétaire (texte)</label>
<input v-model="form.imputation_budgetaire" type="text"
class="mt-1 block w-full rounded-lg border border-gray-300 px-3 py-2 text-sm shadow-sm focus:border-blue-500 focus:outline-none" />
</div>
<div class="sm:col-span-2">
<label class="block text-sm font-medium text-gray-700">Ligne Budgétaire (Module Budget)</label>
<SearchableSelect
v-model="form.ligne_budget_id"
:options="ligneBudgetOptions"
placeholder="Rechercher un projet / enveloppe..."
:class="form.errors.ligne_budget_id ? 'ring-1 ring-red-500 rounded-lg' : ''"
/>
<p v-if="form.errors.ligne_budget_id" class="mt-1 text-xs text-red-600">{{ form.errors.ligne_budget_id }}</p>
</div>
<div class="sm:col-span-2">
<label class="block text-sm font-medium text-gray-700">Description</label>
<textarea v-model="form.description" rows="2"

View File

@@ -135,7 +135,13 @@ const transitionColors = {
<p class="text-xs text-gray-500">Référence fournisseur</p>
<p class="mt-0.5 font-medium text-gray-900">{{ commande.reference_fournisseur }}</p>
</div>
<div v-if="commande.imputation_budgetaire">
<div v-if="commande.ligne_budget_id && commande.ligne_budget">
<p class="text-xs text-indigo-600 font-semibold">Ligne Budgétaire</p>
<Link :href="route('budgets.show', commande.ligne_budget.budget_id)" class="mt-0.5 font-medium text-indigo-700 hover:underline">
{{ commande.ligne_budget.nom }} <span class="text-xs text-gray-500">({{ commande.ligne_budget.budget.annee }})</span>
</Link>
</div>
<div v-else-if="commande.imputation_budgetaire">
<p class="text-xs text-gray-500">Imputation budgétaire</p>
<p class="mt-0.5 font-medium text-gray-900">{{ commande.imputation_budgetaire }}</p>
</div>