Checkpoint: Audit de robustesse et maintenabilité : calcul dynamique des tendances OPEX par véritable année N-1, filtrage/validation centralisés des codes établissement, transactions d'import et d'écriture, contraintes uniques CAPEX/OPEX avec déduplication, suppression effective de postes OPEX, nettoyage des gabarits et scripts morts, chargement différé XLSX, documentation métier et 22 tests passants.
This commit is contained in:
@@ -16,7 +16,6 @@ import {
|
||||
Eye,
|
||||
EyeOff,
|
||||
ArrowUpDown,
|
||||
Save,
|
||||
Lock,
|
||||
CheckCircle2,
|
||||
PencilLine,
|
||||
@@ -35,6 +34,8 @@ import { useAnnee } from '../contexts/AnneeContext';
|
||||
import opexRaw from '../data_opex.json';
|
||||
import opex2025Raw from '../data_opex_2025.json';
|
||||
import { formatEuros } from '../lib/format';
|
||||
import { calculateCategoryTrends } from '../lib/opexTrends';
|
||||
import { isOpexEtablissementCode, normalizeOpexEtablissementCode } from '@shared/opexValidation';
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
@@ -81,20 +82,12 @@ interface EtabSource {
|
||||
base_repartition: number;
|
||||
}
|
||||
|
||||
interface TendanceCategorie {
|
||||
montant_n1: number | null;
|
||||
montant_n: number;
|
||||
variation_pct: number | null;
|
||||
tendance: 'hausse' | 'baisse' | 'stable' | 'new';
|
||||
}
|
||||
|
||||
interface OpexData {
|
||||
annee: number;
|
||||
total_global: number;
|
||||
postes: Poste[];
|
||||
etablissements: EtabSource[];
|
||||
categories_totaux: Record<string, number>;
|
||||
tendances_categories?: Record<string, TendanceCategorie>;
|
||||
meta: { nb_postes: number; nb_etablissements?: number };
|
||||
}
|
||||
|
||||
@@ -117,6 +110,25 @@ interface LigneOpex {
|
||||
colIdx: number;
|
||||
}
|
||||
|
||||
/** Sous-ensemble commun aux lignes brutes OPEX retournées par la BDD. */
|
||||
interface OpexPosteRow {
|
||||
id: number;
|
||||
libelle: string;
|
||||
fournisseur: string | null;
|
||||
categorie: string | null;
|
||||
type: string | null;
|
||||
facturation: string | null;
|
||||
compte: string | null;
|
||||
detail: string | null;
|
||||
budgetN1: string | null;
|
||||
montant: string | null;
|
||||
isCustom: boolean | null;
|
||||
modeVentilation: string | null;
|
||||
libelleCourt: string | null;
|
||||
libelleDetail: string | null;
|
||||
colIdx: number;
|
||||
}
|
||||
|
||||
// ─── Constantes ───────────────────────────────────────────────────────────────
|
||||
|
||||
const opexData2026 = opexRaw as OpexData;
|
||||
@@ -185,6 +197,29 @@ function buildPostesFromSource(annee: number) {
|
||||
}));
|
||||
}
|
||||
|
||||
/** Convertit les lignes Drizzle en modèle de vue, en écartant les synthèses importées. */
|
||||
function mapDbPostesToLignes(postes: OpexPosteRow[]): LigneOpex[] {
|
||||
return postes
|
||||
.filter(p => !isLibelleParasite(p.libelle))
|
||||
.map(p => ({
|
||||
id: p.id,
|
||||
libelle: p.libelle,
|
||||
fournisseur: p.fournisseur ?? '',
|
||||
categorie: p.categorie ?? 'Autre',
|
||||
type: p.type ?? '',
|
||||
facturation: p.facturation ?? '',
|
||||
compte: p.compte ?? '',
|
||||
detail: p.detail ?? '',
|
||||
budget_n1: parseFloat(p.budgetN1 ?? '0') || 0,
|
||||
montant: parseFloat(p.montant ?? '0') || 0,
|
||||
isCustom: p.isCustom ?? false,
|
||||
mode_ventilation: p.modeVentilation ?? null,
|
||||
libelleCourt: p.libelleCourt ?? null,
|
||||
libelleDetail: p.libelleDetail ?? null,
|
||||
colIdx: p.colIdx,
|
||||
}));
|
||||
}
|
||||
|
||||
const EMPTY_LIGNE_FORM = {
|
||||
libelle: '',
|
||||
fournisseur: '',
|
||||
@@ -554,19 +589,13 @@ function ClesRepartitionView({ annee, basesRepartitionRaw, isLoadingBases, isErr
|
||||
}
|
||||
|
||||
// ── Construction des lignes à importer ───────────────────────────────
|
||||
// Mots-clés qui indiquent une ligne parasite (catégorie ou synthèse, pas un établissement)
|
||||
const PARASITE_PATTERNS = /^(infog|s.curit|t.l.phonie|app global|app hep|app smr|applicatifs|total|ventilation|r.partition|nouveaut)/i;
|
||||
const importRows = data.slice(headerIdx + 1)
|
||||
.filter(row => {
|
||||
const code = String(row[codeIdx] ?? '').trim();
|
||||
if (!code) return false;
|
||||
// Rejeter les lignes dont le code ressemble à un libellé de catégorie
|
||||
const codeNorm = code.normalize('NFD').replace(/[\u0300-\u036f]/g, '');
|
||||
if (PARASITE_PATTERNS.test(codeNorm)) return false;
|
||||
return true;
|
||||
return isOpexEtablissementCode(code);
|
||||
})
|
||||
.map(row => ({
|
||||
etablissementCode: String(row[codeIdx]).trim(),
|
||||
etablissementCode: normalizeOpexEtablissementCode(String(row[codeIdx])),
|
||||
etablissementNom: nomIdx >= 0 ? String(row[nomIdx]).trim() || null : null,
|
||||
baseRepartition: parseFloat(String(row[baseIdx]).replace(/[^0-9.,]/g, '').replace(',', '.')) || 0,
|
||||
baseRepartitionHep: hepIdx >= 0
|
||||
@@ -817,6 +846,9 @@ export default function DsiOpex() {
|
||||
|
||||
// ── Requêtes tRPC ──────────────────────────────────────────────────────────
|
||||
const { data: postesRaw, isLoading: loadingPostes } = trpc.opex.getPostes.useQuery({ annee });
|
||||
// Les vignettes doivent comparer l'exercice affiché avec ses données BDD N-1,
|
||||
// et non avec les tendances figées du fichier source 2026.
|
||||
const { data: postesN1Raw, isLoading: loadingPostesN1 } = trpc.opex.getPostes.useQuery({ annee: annee - 1 });
|
||||
const { data: montantsEtabRaw, isLoading: loadingMontants } = trpc.opex.getMontantsEtab.useQuery({ annee });
|
||||
const { data: validatedRow } = trpc.opex.getValidated.useQuery({ annee });
|
||||
// Bases de répartition depuis la BDD (charges classe 6 par établissement)
|
||||
@@ -827,6 +859,14 @@ export default function DsiOpex() {
|
||||
onSuccess: () => utils.opex.getPostes.invalidate({ annee }),
|
||||
onError: (err) => toast.error('Erreur sauvegarde', { description: err.message }),
|
||||
});
|
||||
const deletePoste = trpc.opex.deletePoste.useMutation({
|
||||
onSuccess: () => {
|
||||
utils.opex.getPostes.invalidate({ annee });
|
||||
utils.opex.getMontantsEtab.invalidate({ annee });
|
||||
toast.success('Poste OPEX supprimé');
|
||||
},
|
||||
onError: (err) => toast.error('Erreur suppression', { description: err.message }),
|
||||
});
|
||||
const initFromSource = trpc.opex.initFromSource.useMutation({
|
||||
onSuccess: () => { utils.opex.getPostes.invalidate({ annee }); toast.success(`OPEX ${annee} initialisé`); },
|
||||
onError: (err) => toast.error('Erreur initialisation', { description: err.message }),
|
||||
@@ -859,27 +899,40 @@ export default function DsiOpex() {
|
||||
// ── Mapper les données BDD vers le format interne ──────────────────────────
|
||||
const lignes: LigneOpex[] = useMemo(() => {
|
||||
if (!postesRaw) return [];
|
||||
return postesRaw
|
||||
.filter(p => !isLibelleParasite(p.libelle))
|
||||
.map(p => ({
|
||||
id: p.id,
|
||||
libelle: p.libelle,
|
||||
fournisseur: p.fournisseur ?? '',
|
||||
categorie: p.categorie ?? 'Autre',
|
||||
type: p.type ?? '',
|
||||
facturation: p.facturation ?? '',
|
||||
compte: p.compte ?? '',
|
||||
detail: p.detail ?? '',
|
||||
budget_n1: parseFloat(p.budgetN1 ?? '0') || 0,
|
||||
montant: parseFloat(p.montant ?? '0') || 0,
|
||||
isCustom: p.isCustom ?? false,
|
||||
mode_ventilation: p.modeVentilation ?? null,
|
||||
libelleCourt: p.libelleCourt ?? null,
|
||||
libelleDetail: p.libelleDetail ?? null,
|
||||
colIdx: p.colIdx,
|
||||
}));
|
||||
return mapDbPostesToLignes(postesRaw);
|
||||
}, [postesRaw]);
|
||||
|
||||
const lignesN1 = useMemo(() => {
|
||||
if (postesN1Raw && postesN1Raw.length > 0) {
|
||||
return mapDbPostesToLignes(postesN1Raw);
|
||||
}
|
||||
|
||||
// Seuls les fichiers 2025 et 2026 sont des sources historiques fiables.
|
||||
// Pour les années sans source ni données BDD, l'interface indique simplement
|
||||
// qu'il n'existe pas de référence N-1 au lieu de réutiliser 2026 par défaut.
|
||||
if (annee - 1 === 2025 || annee - 1 === 2026) {
|
||||
return buildPostesFromSource(annee - 1).map((poste, index) => ({
|
||||
id: -(index + 1),
|
||||
libelle: poste.libelle,
|
||||
fournisseur: poste.fournisseur ?? '',
|
||||
categorie: poste.categorie ?? 'Autre',
|
||||
type: poste.type ?? '',
|
||||
facturation: poste.facturation ?? '',
|
||||
compte: poste.compte ?? '',
|
||||
detail: poste.detail ?? '',
|
||||
budget_n1: parseFloat(poste.budgetN1 ?? '0') || 0,
|
||||
montant: parseFloat(poste.montant ?? '0') || 0,
|
||||
isCustom: poste.isCustom ?? false,
|
||||
mode_ventilation: poste.modeVentilation ?? null,
|
||||
libelleCourt: poste.libelleCourt ?? null,
|
||||
libelleDetail: poste.libelleDetail ?? null,
|
||||
colIdx: poste.colIdx,
|
||||
}));
|
||||
}
|
||||
|
||||
return [];
|
||||
}, [annee, postesN1Raw]);
|
||||
|
||||
// Overrides par établissement : { codeEtab: { libellePoste: montant } }
|
||||
const montantsManuelEtab = useMemo(() => {
|
||||
if (!montantsEtabRaw) return {} as Record<string, Record<string, number>>;
|
||||
@@ -1011,15 +1064,11 @@ export default function DsiOpex() {
|
||||
});
|
||||
}, [editingLigne, upsertPoste, annee]);
|
||||
|
||||
const handleDeleteLigne = useCallback((id: number) => {
|
||||
// On ne peut pas vraiment "supprimer" via upsert — on utilise deleteAll + réinsertion
|
||||
// Pour simplifier, on marque le poste avec montant 0 et libelle préfixé [SUPPRIMÉ]
|
||||
// Mais la meilleure approche est d'ajouter une procédure deletePoste
|
||||
// Pour l'instant, on utilise une mutation directe via deleteAll n'est pas approprié
|
||||
// On va ajouter une procédure deletePoste dans le routeur
|
||||
toast.error('Suppression non disponible — rechargez la page après avoir ajouté la procédure deletePoste');
|
||||
const handleDeleteLigne = useCallback(() => {
|
||||
if (!deleteConfirmId) return;
|
||||
deletePoste.mutate({ annee, id: deleteConfirmId });
|
||||
setDeleteConfirmId(null);
|
||||
}, []);
|
||||
}, [annee, deleteConfirmId, deletePoste]);
|
||||
|
||||
const handleInlineEditStart = useCallback((codeEtab: string, libelle: string, currentVal: number) => {
|
||||
if (isValidated) return;
|
||||
@@ -1091,6 +1140,11 @@ export default function DsiOpex() {
|
||||
return totaux;
|
||||
}, [lignes]);
|
||||
|
||||
const tendancesParCategorie = useMemo(
|
||||
() => calculateCategoryTrends(lignes, lignesN1),
|
||||
[lignes, lignesN1],
|
||||
);
|
||||
|
||||
const filteredLignes = useMemo(() => {
|
||||
return lignes.filter(l => {
|
||||
if (selectedCategorie !== 'Toutes' && l.categorie !== selectedCategorie) return false;
|
||||
@@ -1332,11 +1386,12 @@ export default function DsiOpex() {
|
||||
const montant = totalParCategorie[cat] || 0;
|
||||
const pct = totalGlobal > 0 ? (montant / totalGlobal * 100).toFixed(1) : '0';
|
||||
const colors = getCatColors(cat);
|
||||
const srcData = getOpexDataForAnnee(annee);
|
||||
const tendanceInfo: TendanceCategorie | undefined = srcData.tendances_categories?.[cat];
|
||||
const tendance = tendanceInfo?.tendance;
|
||||
const variationPct = tendanceInfo?.variation_pct;
|
||||
const montantN1 = tendanceInfo?.montant_n1;
|
||||
const tendanceInfo = tendancesParCategorie[cat];
|
||||
// Tant que la requête N-1 est active, ne pas présenter de badge
|
||||
// temporairement erroné (par exemple « Nouveau »).
|
||||
const tendance = loadingPostesN1 ? undefined : tendanceInfo?.tendance;
|
||||
const variationPct = tendanceInfo?.variationPct;
|
||||
const montantN1 = tendanceInfo?.montantN1;
|
||||
return (
|
||||
<div key={cat} className="bg-card border border-border rounded-xl p-4 min-w-[140px] flex-1">
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
@@ -2000,7 +2055,7 @@ export default function DsiOpex() {
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Annuler</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={() => deleteConfirmId && handleDeleteLigne(deleteConfirmId)}
|
||||
onClick={handleDeleteLigne}
|
||||
className="bg-destructive hover:bg-destructive/90 text-white"
|
||||
>
|
||||
Supprimer
|
||||
|
||||
Reference in New Issue
Block a user