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:
Manus
2026-08-17 20:50:33 +00:00
parent 31cdb257fb
commit 615dd46059
29 changed files with 1449 additions and 3307 deletions

View File

@@ -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