Checkpoint: Ajout de l'onglet 'Clés de répartition' dans DSI OPEX : tableau éditable avec base de répartition standard et base /HEP par établissement, import fichier Excel/CSV, info année (OPEX N → base N-2). Calcul automatique du poste SAAS DUI Pôle HEP branché sur la colonne /HEP. Nouvelle table opex_bases_repartition avec colonne baseRepartitionHep. Seed 2026 : 64 établissements dont 19 HEP. 14 tests passants, 0 erreur TypeScript.

This commit is contained in:
Manus
2026-06-11 07:20:24 +00:00
parent 14a660892a
commit 841ff1bd09
9 changed files with 1310 additions and 21 deletions

View File

@@ -25,6 +25,9 @@ import {
Pencil,
Trash2,
Loader2,
Key,
Upload,
AlertCircle,
} from 'lucide-react';
import { AppSidebar } from '../components/AppSidebar';
import { AnneeSelectorBar } from '../components/AnneeSelectorBar';
@@ -373,9 +376,265 @@ function LigneForm({
);
}
// ─── Composant Clés de répartition ──────────────────────────────────────────
interface BaseRow {
annee: number;
etablissementCode: string;
etablissementNom: string | null;
baseRepartition: string;
baseRepartitionHep: string | null;
}
interface ClesRepartitionViewProps {
annee: number;
basesRepartitionRaw: BaseRow[];
setBaseRepartition: ReturnType<typeof trpc.opex.setBaseRepartition.useMutation>;
importBasesRepartition: ReturnType<typeof trpc.opex.importBasesRepartition.useMutation>;
}
function ClesRepartitionView({ annee, basesRepartitionRaw, setBaseRepartition, importBasesRepartition }: ClesRepartitionViewProps) {
const anneeBase = annee - 2; // La base de répartition correspond à l'année OPEX - 2
const [editingCell, setEditingCell] = useState<{ code: string; col: 'base' | 'hep' } | null>(null);
const [editValue, setEditValue] = useState('');
const [searchBase, setSearchBase] = useState('');
const [isDragging, setIsDragging] = useState(false);
const fileInputRef = React.useRef<HTMLInputElement>(null);
const rows = useMemo(() => {
return basesRepartitionRaw
.filter(r => {
if (!searchBase) return true;
const s = searchBase.toLowerCase();
return r.etablissementCode.toLowerCase().includes(s) || (r.etablissementNom ?? '').toLowerCase().includes(s);
})
.sort((a, b) => a.etablissementCode.localeCompare(b.etablissementCode));
}, [basesRepartitionRaw, searchBase]);
const totalBase = useMemo(() => basesRepartitionRaw.reduce((s, r) => s + parseFloat(r.baseRepartition ?? '0'), 0), [basesRepartitionRaw]);
const totalHep = useMemo(() => basesRepartitionRaw.reduce((s, r) => s + parseFloat(r.baseRepartitionHep ?? '0'), 0), [basesRepartitionRaw]);
function startEdit(code: string, col: 'base' | 'hep', currentVal: string) {
setEditingCell({ code, col });
setEditValue(currentVal.replace(/[^0-9.]/g, ''));
}
function commitEdit(row: BaseRow) {
if (!editingCell) return;
const numVal = parseFloat(editValue.replace(',', '.')) || 0;
const baseRepartition = editingCell.col === 'base' ? numVal : parseFloat(row.baseRepartition ?? '0');
const baseRepartitionHep = editingCell.col === 'hep' ? numVal : parseFloat(row.baseRepartitionHep ?? '0');
setBaseRepartition.mutate({
annee,
etablissementCode: row.etablissementCode,
etablissementNom: row.etablissementNom,
baseRepartition,
baseRepartitionHep,
});
setEditingCell(null);
}
async function handleFileImport(file: File) {
try {
const XLSX = await import('xlsx');
const buf = await file.arrayBuffer();
const wb = XLSX.read(buf, { type: 'array' });
const ws = wb.Sheets[wb.SheetNames[0]];
const data: string[][] = XLSX.utils.sheet_to_json(ws, { header: 1, defval: '' }) as string[][];
// Trouver la ligne d'en-tête
let headerIdx = -1;
for (let i = 0; i < Math.min(data.length, 10); i++) {
const row = data[i].map(c => String(c).toLowerCase());
if (row.some(c => c.includes('code') || c.includes('établissement') || c.includes('etab'))) {
headerIdx = i;
break;
}
}
if (headerIdx === -1) { toast.error('Format non reconnu', { description: 'Impossible de trouver la ligne d\'en-tête (code établissement)' }); return; }
const headers = data[headerIdx].map(c => String(c).toLowerCase());
const codeIdx = headers.findIndex(h => h.includes('code'));
const nomIdx = headers.findIndex(h => h.includes('nom') || h.includes('établissement') || h.includes('etab'));
const baseIdx = headers.findIndex(h => (h.includes('base') || h.includes('répartition') || h.includes('repartition')) && !h.includes('hep'));
const hepIdx = headers.findIndex(h => h.includes('hep'));
if (codeIdx === -1 || baseIdx === -1) { toast.error('Colonnes manquantes', { description: 'Le fichier doit contenir au minimum les colonnes : code établissement, base de répartition' }); return; }
const importRows = data.slice(headerIdx + 1)
.filter(row => row[codeIdx] && String(row[codeIdx]).trim())
.map(row => ({
etablissementCode: String(row[codeIdx]).trim(),
etablissementNom: nomIdx >= 0 ? String(row[nomIdx]).trim() || null : null,
baseRepartition: parseFloat(String(row[baseIdx]).replace(/[^0-9.]/g, '')) || 0,
baseRepartitionHep: hepIdx >= 0 ? parseFloat(String(row[hepIdx]).replace(/[^0-9.]/g, '')) || 0 : 0,
}));
if (importRows.length === 0) { toast.error('Aucune ligne importée', { description: 'Le fichier ne contient aucune ligne de données valide' }); return; }
importBasesRepartition.mutate({ annee, rows: importRows });
} catch (e) {
toast.error('Erreur lecture fichier', { description: String(e) });
}
}
function formatBaseNum(val: string | null | undefined): string {
const n = parseFloat(val ?? '0');
if (!n) return '—';
return new Intl.NumberFormat('fr-FR', { maximumFractionDigits: 0 }).format(n) + ' €';
}
return (
<div className="flex flex-col h-full overflow-hidden">
{/* Bandeau d'information */}
<div className="flex-shrink-0 mb-4 p-4 rounded-xl bg-blue-50 border border-blue-200 flex items-start gap-3">
<AlertCircle className="w-5 h-5 text-blue-600 flex-shrink-0 mt-0.5" />
<div className="text-sm text-blue-800">
<p className="font-semibold mb-1">Base de répartition {anneeBase} OPEX {annee}</p>
<p>La base de répartition utilisée pour l'OPEX <strong>{annee}</strong> correspond aux charges de classe 6 de l'exercice <strong>{anneeBase}</strong> (année OPEX 2).</p>
<p className="mt-1">La colonne <strong>Base / HEP</strong> est utilisée exclusivement pour le poste <em>DUI pôle HEP</em>. Pour les établissements hors pôle HEP, cette valeur doit être 0.</p>
</div>
</div>
{/* Barre d'outils */}
<div className="flex-shrink-0 flex items-center gap-3 mb-3 flex-wrap">
<div className="relative flex-1 min-w-48 max-w-xs">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
<input
type="text"
placeholder="Rechercher un établissement..."
value={searchBase}
onChange={e => setSearchBase(e.target.value)}
className="w-full pl-9 pr-3 py-2 text-sm bg-muted/50 border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary/30 focus:border-primary transition-colors"
/>
</div>
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<span className="font-medium text-foreground">{basesRepartitionRaw.length}</span> établissements
</div>
<div className="ml-auto flex items-center gap-2">
<input
ref={fileInputRef}
type="file"
accept=".xlsx,.xls,.csv"
className="hidden"
onChange={e => { const f = e.target.files?.[0]; if (f) handleFileImport(f); e.target.value = ''; }}
/>
<button
onClick={() => fileInputRef.current?.click()}
disabled={importBasesRepartition.isPending}
className="flex items-center gap-1.5 px-3 py-2 text-sm rounded-lg border border-border bg-card hover:bg-muted/50 transition-colors disabled:opacity-50"
>
{importBasesRepartition.isPending ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Upload className="w-3.5 h-3.5" />}
Importer fichier
</button>
</div>
</div>
{/* Zone de drop */}
<div
className={`flex-shrink-0 mb-3 border-2 border-dashed rounded-xl p-3 text-center text-xs text-muted-foreground transition-colors ${
isDragging ? 'border-primary bg-primary/5 text-primary' : 'border-border hover:border-primary/40'
}`}
onDragOver={e => { e.preventDefault(); setIsDragging(true); }}
onDragLeave={() => setIsDragging(false)}
onDrop={e => { e.preventDefault(); setIsDragging(false); const f = e.dataTransfer.files?.[0]; if (f) handleFileImport(f); }}
>
Glissez un fichier Excel (.xlsx) ou CSV ici pour importer les bases de répartition
<span className="block mt-0.5 text-muted-foreground/60">Colonnes attendues : code établissement · base de répartition · base répartition HEP (optionnel)</span>
</div>
{/* Tableau */}
<div className="flex-1 overflow-auto rounded-xl border border-border">
<table className="w-full text-sm border-collapse">
<thead>
<tr className="bg-muted/40 border-b border-border sticky top-0 z-10">
<th className="px-4 py-3 text-left font-semibold text-foreground text-xs uppercase tracking-wide w-28">Code</th>
<th className="px-4 py-3 text-left font-semibold text-foreground text-xs uppercase tracking-wide">Établissement</th>
<th className="px-4 py-3 text-right font-semibold text-foreground text-xs uppercase tracking-wide w-48">
Base de répartition {anneeBase}
</th>
<th className="px-4 py-3 text-right font-semibold text-foreground text-xs uppercase tracking-wide w-48">
Base répartition {anneeBase} / HEP
</th>
</tr>
</thead>
<tbody>
{rows.length === 0 && (
<tr>
<td colSpan={4} className="px-4 py-12 text-center text-muted-foreground">
{basesRepartitionRaw.length === 0
? `Aucune base de répartition pour ${annee}. Importez un fichier ou saisissez les valeurs manuellement.`
: 'Aucun résultat pour cette recherche.'}
</td>
</tr>
)}
{rows.map((row, idx) => (
<tr key={row.etablissementCode} className={`border-b border-border/50 hover:bg-muted/20 transition-colors ${idx % 2 === 0 ? '' : 'bg-muted/10'}`}>
<td className="px-4 py-2.5 font-mono text-xs text-muted-foreground">{row.etablissementCode}</td>
<td className="px-4 py-2.5 font-medium text-foreground">{row.etablissementNom ?? row.etablissementCode}</td>
{/* Colonne Base de répartition */}
<td className="px-4 py-2.5 text-right">
{editingCell?.code === row.etablissementCode && editingCell.col === 'base' ? (
<input
type="number"
value={editValue}
onChange={e => setEditValue(e.target.value)}
onBlur={() => commitEdit(row)}
onKeyDown={e => { if (e.key === 'Enter') commitEdit(row); if (e.key === 'Escape') setEditingCell(null); }}
className="w-full text-right px-2 py-1 border border-primary rounded text-sm focus:outline-none focus:ring-2 focus:ring-primary/30 tabular-nums"
autoFocus
/>
) : (
<button
onClick={() => startEdit(row.etablissementCode, 'base', row.baseRepartition)}
className="w-full text-right px-2 py-1 rounded hover:bg-primary/10 transition-colors tabular-nums font-medium text-foreground group"
>
{formatBaseNum(row.baseRepartition)}
<Pencil className="inline-block ml-1 w-3 h-3 text-muted-foreground opacity-0 group-hover:opacity-100 transition-opacity" />
</button>
)}
</td>
{/* Colonne Base / HEP */}
<td className="px-4 py-2.5 text-right">
{editingCell?.code === row.etablissementCode && editingCell.col === 'hep' ? (
<input
type="number"
value={editValue}
onChange={e => setEditValue(e.target.value)}
onBlur={() => commitEdit(row)}
onKeyDown={e => { if (e.key === 'Enter') commitEdit(row); if (e.key === 'Escape') setEditingCell(null); }}
className="w-full text-right px-2 py-1 border border-primary rounded text-sm focus:outline-none focus:ring-2 focus:ring-primary/30 tabular-nums"
autoFocus
/>
) : (
<button
onClick={() => startEdit(row.etablissementCode, 'hep', row.baseRepartitionHep ?? '0')}
className={`w-full text-right px-2 py-1 rounded hover:bg-primary/10 transition-colors tabular-nums group ${
parseFloat(row.baseRepartitionHep ?? '0') > 0 ? 'font-medium text-orange-600' : 'text-muted-foreground'
}`}
>
{parseFloat(row.baseRepartitionHep ?? '0') > 0 ? formatBaseNum(row.baseRepartitionHep) : '—'}
<Pencil className="inline-block ml-1 w-3 h-3 text-muted-foreground opacity-0 group-hover:opacity-100 transition-opacity" />
</button>
)}
</td>
</tr>
))}
</tbody>
<tfoot>
<tr className="bg-muted/40 border-t-2 border-border font-bold">
<td colSpan={2} className="px-4 py-3 text-sm font-semibold text-foreground">TOTAL {basesRepartitionRaw.length} établissements</td>
<td className="px-4 py-3 text-right text-sm font-bold text-foreground tabular-nums">
{new Intl.NumberFormat('fr-FR', { maximumFractionDigits: 0 }).format(totalBase)}
</td>
<td className="px-4 py-3 text-right text-sm font-bold text-orange-600 tabular-nums">
{totalHep > 0 ? new Intl.NumberFormat('fr-FR', { maximumFractionDigits: 0 }).format(totalHep) + ' €' : '—'}
</td>
</tr>
</tfoot>
</table>
</div>
</div>
);
}
// ─── Composant principal ──────────────────────────────────────────────────────
type ViewMode = 'postes' | 'etablissements';
type ViewMode = 'postes' | 'etablissements' | 'cles_repartition';
type SortDir = 'asc' | 'desc';
export default function DsiOpex() {
@@ -410,6 +669,14 @@ export default function DsiOpex() {
onSuccess: () => { utils.opex.getValidated.invalidate({ annee }); toast.success(`OPEX ${annee} validé`, { description: 'Le prévisionnel est maintenant verrouillé.' }); },
onError: (err) => toast.error('Erreur validation', { description: err.message }),
});
const setBaseRepartition = trpc.opex.setBaseRepartition.useMutation({
onSuccess: () => utils.opex.getBasesRepartition.invalidate({ annee }),
onError: (err) => toast.error('Erreur sauvegarde base', { description: err.message }),
});
const importBasesRepartition = trpc.opex.importBasesRepartition.useMutation({
onSuccess: (data) => { utils.opex.getBasesRepartition.invalidate({ annee }); toast.success(`${data.count} bases de répartition importées`); },
onError: (err) => toast.error('Erreur import', { description: err.message }),
});
// ── Mapper les données BDD vers le format interne ──────────────────────────
const lignes: LigneOpex[] = useMemo(() => {
@@ -696,29 +963,39 @@ export default function DsiOpex() {
code: b.etablissementCode,
nom: b.etablissementNom ?? b.etablissementCode,
base_repartition: parseFloat(b.baseRepartition ?? '0') || 0,
base_repartition_hep: parseFloat(b.baseRepartitionHep ?? '0') || 0,
}))
: getOpexDataForAnnee(annee).etablissements.map(e => ({
code: e.code,
nom: e.nom,
base_repartition: e.base_repartition,
base_repartition_hep: 0, // JSON source n'a pas de base HEP
}));
const totalBase = basesFromBdd.reduce((s, e) => s + e.base_repartition, 0);
const totalBaseHep = basesFromBdd.reduce((s, e) => s + e.base_repartition_hep, 0);
if (totalBase === 0) return basesFromBdd.map(e => ({ ...e, montants: {} as Record<string, number>, total: 0 }));
return basesFromBdd.map(etab => {
const ratio = etab.base_repartition / totalBase;
// Ratio HEP : utiliser la base /HEP si disponible, sinon fallback sur base standard
const ratioHep = totalBaseHep > 0 ? etab.base_repartition_hep / totalBaseHep : ratio;
const etabOverrides: Record<string, number> = montantsManuelEtab[etab.code] ?? {};
const montantsRecalcules: Record<string, number> = {};
let totalEtab = 0;
for (const ligne of lignes) {
const isManuel = ligne.mode_ventilation === 'Manuel' || ligne.isCustom;
// Le poste "SAAS DUI Pole HEP" utilise la base de répartition /HEP
const isHepPoste = ligne.libelle.toLowerCase().includes('dui') &&
(ligne.libelle.toLowerCase().includes('hep') ||
(ligne.libelleDetail ?? '').toLowerCase().includes('hep'));
const ratioApplique = isHepPoste ? ratioHep : ratio;
let montant: number;
if (isManuel && etabOverrides[ligne.libelle] !== undefined) {
montant = etabOverrides[ligne.libelle];
} else if (!isManuel && ligne.montant > 0) {
montant = Math.round(ligne.montant * ratio);
montant = Math.round(ligne.montant * ratioApplique);
} else {
montant = 0;
}
@@ -978,6 +1255,15 @@ export default function DsiOpex() {
<Building2 className="w-3.5 h-3.5" />
Par établissement
</button>
<button
onClick={() => { setViewMode('cles_repartition'); setSortCol(null); }}
className={`px-3 py-1.5 rounded-md text-xs font-medium transition-all flex items-center gap-1.5 ${
viewMode === 'cles_repartition' ? 'bg-card text-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground'
}`}
>
<Key className="w-3.5 h-3.5" />
Clés de répartition
</button>
</div>
<button
onClick={() => setShowDetails(d => !d)}
@@ -1440,6 +1726,15 @@ export default function DsiOpex() {
</div>
</div>
)}
{/* Vue Clés de répartition */}
{viewMode === 'cles_repartition' && (
<ClesRepartitionView
annee={annee}
basesRepartitionRaw={basesRepartitionRaw ?? []}
setBaseRepartition={setBaseRepartition}
importBasesRepartition={importBasesRepartition}
/>
)}
</div>
</main>

View File

@@ -0,0 +1 @@
ALTER TABLE `opex_bases_repartition` ADD `baseRepartitionHep` decimal(20,6) DEFAULT '0';

View File

@@ -0,0 +1,872 @@
{
"version": "5",
"dialect": "mysql",
"id": "baff50d1-531e-4ba7-a82a-6f9c64aa1186",
"prevId": "7cfbd553-42c7-4195-8927-d7db39cd57d6",
"tables": {
"capex_lignes": {
"name": "capex_lignes",
"columns": {
"id": {
"name": "id",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": true
},
"annee": {
"name": "annee",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"etablissementCode": {
"name": "etablissementCode",
"type": "varchar(50)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"cle": {
"name": "cle",
"type": "varchar(100)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"montant": {
"name": "montant",
"type": "decimal(12,2)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"createdAt": {
"name": "createdAt",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(now())"
},
"updatedAt": {
"name": "updatedAt",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"onUpdate": true,
"default": "(now())"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {
"capex_lignes_id": {
"name": "capex_lignes_id",
"columns": [
"id"
]
}
},
"uniqueConstraints": {},
"checkConstraint": {}
},
"etablissements": {
"name": "etablissements",
"columns": {
"id": {
"name": "id",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": true
},
"code": {
"name": "code",
"type": "varchar(50)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"nom": {
"name": "nom",
"type": "varchar(255)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"groupe": {
"name": "groupe",
"type": "varchar(100)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"ville": {
"name": "ville",
"type": "varchar(100)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"actif": {
"name": "actif",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": true
},
"createdAt": {
"name": "createdAt",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(now())"
},
"updatedAt": {
"name": "updatedAt",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"onUpdate": true,
"default": "(now())"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {
"etablissements_id": {
"name": "etablissements_id",
"columns": [
"id"
]
}
},
"uniqueConstraints": {
"etablissements_code_unique": {
"name": "etablissements_code_unique",
"columns": [
"code"
]
}
},
"checkConstraint": {}
},
"inventaire_meta": {
"name": "inventaire_meta",
"columns": {
"id": {
"name": "id",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": true
},
"annee": {
"name": "annee",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"filename": {
"name": "filename",
"type": "varchar(255)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"dateImport": {
"name": "dateImport",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(now())"
},
"nbEtablissements": {
"name": "nbEtablissements",
"type": "int",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": 0
},
"nbFixes": {
"name": "nbFixes",
"type": "int",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": 0
},
"nbPortables": {
"name": "nbPortables",
"type": "int",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": 0
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {
"inventaire_meta_id": {
"name": "inventaire_meta_id",
"columns": [
"id"
]
}
},
"uniqueConstraints": {
"inventaire_meta_annee_unique": {
"name": "inventaire_meta_annee_unique",
"columns": [
"annee"
]
}
},
"checkConstraint": {}
},
"inventaire_postes": {
"name": "inventaire_postes",
"columns": {
"id": {
"name": "id",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": true
},
"annee": {
"name": "annee",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"etablissementCode": {
"name": "etablissementCode",
"type": "varchar(50)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"libelle": {
"name": "libelle",
"type": "varchar(255)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"typePoste": {
"name": "typePoste",
"type": "enum('fixe','portable')",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"dateRef": {
"name": "dateRef",
"type": "varchar(20)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"ageAns": {
"name": "ageAns",
"type": "decimal(5,2)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"modele": {
"name": "modele",
"type": "varchar(255)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"fabricant": {
"name": "fabricant",
"type": "varchar(100)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"createdAt": {
"name": "createdAt",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(now())"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {
"inventaire_postes_id": {
"name": "inventaire_postes_id",
"columns": [
"id"
]
}
},
"uniqueConstraints": {},
"checkConstraint": {}
},
"opex_bases_repartition": {
"name": "opex_bases_repartition",
"columns": {
"id": {
"name": "id",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": true
},
"annee": {
"name": "annee",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"etablissementCode": {
"name": "etablissementCode",
"type": "varchar(50)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"etablissementNom": {
"name": "etablissementNom",
"type": "varchar(255)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"baseRepartition": {
"name": "baseRepartition",
"type": "decimal(20,6)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"baseRepartitionHep": {
"name": "baseRepartitionHep",
"type": "decimal(20,6)",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": "'0'"
},
"createdAt": {
"name": "createdAt",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(now())"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {
"opex_bases_repartition_id": {
"name": "opex_bases_repartition_id",
"columns": [
"id"
]
}
},
"uniqueConstraints": {},
"checkConstraint": {}
},
"opex_montants_etab": {
"name": "opex_montants_etab",
"columns": {
"id": {
"name": "id",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": true
},
"annee": {
"name": "annee",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"etablissementCode": {
"name": "etablissementCode",
"type": "varchar(50)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"libellePoste": {
"name": "libellePoste",
"type": "varchar(255)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"montant": {
"name": "montant",
"type": "decimal(12,2)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"createdAt": {
"name": "createdAt",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(now())"
},
"updatedAt": {
"name": "updatedAt",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"onUpdate": true,
"default": "(now())"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {
"opex_montants_etab_id": {
"name": "opex_montants_etab_id",
"columns": [
"id"
]
}
},
"uniqueConstraints": {},
"checkConstraint": {}
},
"opex_postes": {
"name": "opex_postes",
"columns": {
"id": {
"name": "id",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": true
},
"annee": {
"name": "annee",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"colIdx": {
"name": "colIdx",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"libelle": {
"name": "libelle",
"type": "varchar(255)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"libelleCourt": {
"name": "libelleCourt",
"type": "varchar(100)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"libelleDetail": {
"name": "libelleDetail",
"type": "varchar(255)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"fournisseur": {
"name": "fournisseur",
"type": "varchar(255)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"categorie": {
"name": "categorie",
"type": "varchar(100)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"type": {
"name": "type",
"type": "varchar(100)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"facturation": {
"name": "facturation",
"type": "varchar(100)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"modeVentilation": {
"name": "modeVentilation",
"type": "varchar(50)",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": "'Prorata C 6'"
},
"compte": {
"name": "compte",
"type": "varchar(50)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"detail": {
"name": "detail",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"budgetN1": {
"name": "budgetN1",
"type": "decimal(12,2)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"montant": {
"name": "montant",
"type": "decimal(12,2)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"isCustom": {
"name": "isCustom",
"type": "boolean",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": false
},
"createdAt": {
"name": "createdAt",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(now())"
},
"updatedAt": {
"name": "updatedAt",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"onUpdate": true,
"default": "(now())"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {
"opex_postes_id": {
"name": "opex_postes_id",
"columns": [
"id"
]
}
},
"uniqueConstraints": {},
"checkConstraint": {}
},
"opex_validated": {
"name": "opex_validated",
"columns": {
"id": {
"name": "id",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": true
},
"annee": {
"name": "annee",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"validatedAt": {
"name": "validatedAt",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(now())"
},
"validatedBy": {
"name": "validatedBy",
"type": "int",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {
"opex_validated_id": {
"name": "opex_validated_id",
"columns": [
"id"
]
}
},
"uniqueConstraints": {
"opex_validated_annee_unique": {
"name": "opex_validated_annee_unique",
"columns": [
"annee"
]
}
},
"checkConstraint": {}
},
"parametres_app": {
"name": "parametres_app",
"columns": {
"id": {
"name": "id",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": true
},
"cle": {
"name": "cle",
"type": "varchar(100)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"valeur": {
"name": "valeur",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"updatedAt": {
"name": "updatedAt",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"onUpdate": true,
"default": "(now())"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {
"parametres_app_id": {
"name": "parametres_app_id",
"columns": [
"id"
]
}
},
"uniqueConstraints": {
"parametres_app_cle_unique": {
"name": "parametres_app_cle_unique",
"columns": [
"cle"
]
}
},
"checkConstraint": {}
},
"user_etablissements": {
"name": "user_etablissements",
"columns": {
"id": {
"name": "id",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": true
},
"userId": {
"name": "userId",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"etablissementCode": {
"name": "etablissementCode",
"type": "varchar(50)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {
"user_etablissements_id": {
"name": "user_etablissements_id",
"columns": [
"id"
]
}
},
"uniqueConstraints": {},
"checkConstraint": {}
},
"users": {
"name": "users",
"columns": {
"id": {
"name": "id",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": true
},
"login": {
"name": "login",
"type": "varchar(255)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"email": {
"name": "email",
"type": "varchar(320)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"passwordHash": {
"name": "passwordHash",
"type": "varchar(255)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"firstName": {
"name": "firstName",
"type": "varchar(100)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"lastName": {
"name": "lastName",
"type": "varchar(100)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"role": {
"name": "role",
"type": "enum('admin','standard','readonly')",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'standard'"
},
"isActive": {
"name": "isActive",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": true
},
"createdAt": {
"name": "createdAt",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(now())"
},
"updatedAt": {
"name": "updatedAt",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"onUpdate": true,
"default": "(now())"
},
"lastSignedIn": {
"name": "lastSignedIn",
"type": "timestamp",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {
"users_id": {
"name": "users_id",
"columns": [
"id"
]
}
},
"uniqueConstraints": {
"users_login_unique": {
"name": "users_login_unique",
"columns": [
"login"
]
}
},
"checkConstraint": {}
}
},
"views": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"tables": {},
"indexes": {}
}
}

View File

@@ -15,6 +15,13 @@
"when": 1781158775157,
"tag": "0001_flaky_kitty_pryde",
"breakpoints": true
},
{
"idx": 2,
"version": "5",
"when": 1781161698458,
"tag": "0002_faithful_famine",
"breakpoints": true
}
]
}

View File

@@ -170,7 +170,10 @@ export const opexBasesRepartition = mysqlTable("opex_bases_repartition", {
annee: int("annee").notNull(),
etablissementCode: varchar("etablissementCode", { length: 50 }).notNull(),
etablissementNom: varchar("etablissementNom", { length: 255 }),
/** Charges classe 6 — base de répartition standard (tous établissements) */
baseRepartition: decimal("baseRepartition", { precision: 20, scale: 6 }).notNull(),
/** Charges classe 6 — base de répartition pôle HEP uniquement (0 pour les étblissements hors HEP) */
baseRepartitionHep: decimal("baseRepartitionHep", { precision: 20, scale: 6 }).default("0"),
createdAt: timestamp("createdAt").defaultNow().notNull(),
});

View File

@@ -1,6 +1,11 @@
/**
* Seed des bases de répartition OPEX (charges classe 6) pour 2025 et 2026
* depuis les fichiers JSON sources.
*
* Inclut désormais la colonne baseRepartitionHep (pôle HEP uniquement).
* Données 2026 extraites du fichier Excel source via extract_bases_repartition.py.
*
* Usage: node scripts/seed-opex-bases.mjs
*/
import mysql from 'mysql2/promise';
import { readFileSync } from 'fs';
@@ -13,41 +18,89 @@ dotenv.config({ path: join(__dirname, '..', '.env') });
const conn = await mysql.createConnection(process.env.DATABASE_URL);
const dataFiles = {
2025: join(__dirname, '../client/src/data_opex_2025.json'),
2026: join(__dirname, '../client/src/data_opex.json'),
};
for (const [annee, filePath] of Object.entries(dataFiles)) {
// ── Année 2025 — depuis le JSON source (pas de colonne HEP disponible) ────────
{
const annee = 2025;
const filePath = join(__dirname, '../client/src/data_opex_2025.json');
const data = JSON.parse(readFileSync(filePath, 'utf8'));
const etabs = data.etablissements || [];
if (etabs.length === 0) {
console.log(`Année ${annee}: aucun établissement trouvé dans le JSON`);
continue;
}
// Supprimer les bases existantes pour cette année
await conn.execute('DELETE FROM opex_bases_repartition WHERE annee = ?', [parseInt(annee)]);
// Insérer en batch
} else {
await conn.execute('DELETE FROM opex_bases_repartition WHERE annee = ?', [annee]);
const values = etabs.map(e => [
parseInt(annee),
annee,
e.code,
e.nom || null,
e.base_repartition || 0,
0, // baseRepartitionHep non disponible pour 2025
]);
await conn.query(
'INSERT INTO opex_bases_repartition (annee, etablissementCode, etablissementNom, baseRepartition, baseRepartitionHep) VALUES ?',
[values]
);
console.log(`Année ${annee}: ${values.length} bases de répartition insérées (HEP: non disponible → 0)`);
console.log(` Total base: ${etabs.reduce((s, e) => s + (e.base_repartition || 0), 0).toLocaleString('fr-FR', { maximumFractionDigits: 0 })} €`);
}
}
// ── Année 2026 — depuis le JSON extrait du fichier Excel source ───────────────
// Données extraites par /home/ubuntu/extract_bases_repartition.py
// Fichier: /home/ubuntu/bases_repartition_2026.json
{
const annee = 2026;
let bases2026;
try {
bases2026 = JSON.parse(readFileSync('/home/ubuntu/bases_repartition_2026.json', 'utf8'));
} catch (e) {
console.error('Fichier bases_repartition_2026.json non trouvé. Fallback sur le JSON source.');
const filePath = join(__dirname, '../client/src/data_opex.json');
const data = JSON.parse(readFileSync(filePath, 'utf8'));
bases2026 = (data.etablissements || []).map(e => ({
code: e.code,
nom: e.nom,
base_repartition: e.base_repartition || 0,
base_repartition_hep: 0,
}));
}
// Filtrer les lignes parasites (totaux, applicatifs, etc.)
const etablissements = bases2026.filter(e => {
const code = String(e.code).trim();
const nom = String(e.nom || '').trim().toLowerCase();
if (code.length < 4) return false;
if (nom.includes('total') || nom.includes('applicatif') || nom.includes('téléphonie')) return false;
// Exclure les lignes dont le code ressemble à un montant (ex: "504980")
if (/^\d{5,}$/.test(code)) return false;
return true;
});
await conn.execute('DELETE FROM opex_bases_repartition WHERE annee = ?', [annee]);
const values = etablissements.map(e => [
annee,
String(e.code).trim(),
String(e.nom || '').trim() || null,
e.base_repartition || 0,
e.base_repartition_hep || 0,
]);
if (values.length > 0) {
await conn.query(
'INSERT INTO opex_bases_repartition (annee, etablissementCode, etablissementNom, baseRepartition) VALUES ?',
'INSERT INTO opex_bases_repartition (annee, etablissementCode, etablissementNom, baseRepartition, baseRepartitionHep) VALUES ?',
[values]
);
}
const totalBase = etablissements.reduce((s, e) => s + (e.base_repartition || 0), 0);
const totalHep = etablissements.reduce((s, e) => s + (e.base_repartition_hep || 0), 0);
const nbHep = etablissements.filter(e => (e.base_repartition_hep || 0) > 0).length;
console.log(`Année ${annee}: ${values.length} bases de répartition insérées`);
console.log(` Total base: ${etabs.reduce((s, e) => s + (e.base_repartition || 0), 0).toLocaleString('fr-FR', { maximumFractionDigits: 0 })} €`);
console.log(` Total base standard: ${totalBase.toLocaleString('fr-FR', { maximumFractionDigits: 0 })} €`);
console.log(` Total base HEP: ${totalHep.toLocaleString('fr-FR', { maximumFractionDigits: 0 })} € (${nbHep} établissements HEP)`);
}
await conn.end();
console.log('\nSeed bases de répartition terminé.');
console.log('\nSeed bases de répartition terminé.');

View File

@@ -343,6 +343,7 @@ export async function upsertOpexBaseRepartition(input: {
etablissementCode: string;
etablissementNom?: string | null;
baseRepartition: number;
baseRepartitionHep?: number;
}) {
const db = await getDb();
if (!db) throw new Error("Database not available");
@@ -353,11 +354,35 @@ export async function upsertOpexBaseRepartition(input: {
etablissementCode: input.etablissementCode,
etablissementNom: input.etablissementNom ?? null,
baseRepartition: String(input.baseRepartition),
baseRepartitionHep: String(input.baseRepartitionHep ?? 0),
})
.onDuplicateKeyUpdate({
set: {
baseRepartition: String(input.baseRepartition),
baseRepartitionHep: String(input.baseRepartitionHep ?? 0),
etablissementNom: input.etablissementNom ?? null,
},
});
}
/** Import en masse des bases de répartition pour une année (supprime et réinsère) */
export async function importOpexBasesRepartition(
annee: number,
rows: Array<{ etablissementCode: string; etablissementNom?: string | null; baseRepartition: number; baseRepartitionHep?: number }>
) {
const db = await getDb();
if (!db) throw new Error("Database not available");
// Supprimer les données existantes pour l'année
await db.delete(opexBasesRepartition).where(eq(opexBasesRepartition.annee, annee));
if (rows.length === 0) return;
// Insérer en batch
await db.insert(opexBasesRepartition).values(
rows.map(r => ({
annee,
etablissementCode: r.etablissementCode,
etablissementNom: r.etablissementNom ?? null,
baseRepartition: String(r.baseRepartition),
baseRepartitionHep: String(r.baseRepartitionHep ?? 0),
}))
);
}

View File

@@ -179,11 +179,33 @@ export const appRouter = router({
.input(z.object({ annee: z.number() }))
.query(async ({ input }) => db.getOpexBasesRepartition(input.annee)),
// Upsert une base de répartition pour un établissement
// Upsert une base de répartition pour un établissement (standard + HEP)
setBaseRepartition: writeProcedure
.input(z.object({ annee: z.number(), etablissementCode: z.string(), etablissementNom: z.string().optional().nullable(), baseRepartition: z.number() }))
.input(z.object({
annee: z.number(),
etablissementCode: z.string(),
etablissementNom: z.string().optional().nullable(),
baseRepartition: z.number(),
baseRepartitionHep: z.number().optional().default(0),
}))
.mutation(async ({ input }) => { await db.upsertOpexBaseRepartition(input); return { success: true }; }),
// Import en masse des bases de répartition (depuis fichier Excel/CSV)
importBasesRepartition: writeProcedure
.input(z.object({
annee: z.number(),
rows: z.array(z.object({
etablissementCode: z.string(),
etablissementNom: z.string().optional().nullable(),
baseRepartition: z.number(),
baseRepartitionHep: z.number().optional().default(0),
})),
}))
.mutation(async ({ input }) => {
await db.importOpexBasesRepartition(input.annee, input.rows);
return { success: true, count: input.rows.length };
}),
// Supprimer toutes les lignes OPEX d'une année
deleteAll: writeProcedure
.input(z.object({ annee: z.number() }))

11
todo.md
View File

@@ -40,3 +40,14 @@
- [ ] Gestion des droits par établissement (userEtablissements)
- [ ] Export PDF/Excel des budgets
- [ ] Historique des modifications (audit log)
## Onglet Clés de répartition dans DSI OPEX
- [ ] Analyser le fichier source pour extraire les colonnes base_repartition et base_repartition_hep
- [ ] Ajouter colonne base_repartition_hep dans la table opex_bases_repartition + migration
- [ ] Routes tRPC : opex.getBasesRepartition (mise à jour), opex.setBaseRepartition (mise à jour avec hep), opex.importBasesRepartition
- [ ] Seeder les données base_repartition_hep 2026 depuis le fichier source
- [ ] Créer l'onglet "Clés de répartition" dans DsiOpex avec tableau éditable (base standard + /HEP)
- [ ] Import fichier Excel/CSV dans l'onglet Clés de répartition
- [ ] Afficher l'info "Année de la base = année OPEX - 2" dans l'onglet
- [ ] Brancher le calcul du poste "DUI pôle HEP" sur base_repartition_hep