Compare commits

...

10 Commits

Author SHA1 Message Date
Manus Deploy
8061887176 feat: ajout Dockerfile, docker-compose et nginx pour déploiement recette 2026-06-11 14:18:26 +00:00
Manus
aa095eae08 Checkpoint: La colonne Code est maintenant cliquable dans la vue Établissements OPEX : clic pour trier par code croissant/décroissant. Le tri par code est aussi le tri par défaut (quand aucune autre colonne n'est sélectionnée). Les colonnes Établissement et Total restent triables comme avant. 2026-06-11 09:31:03 +00:00
Manus
f0737a290e Checkpoint: Quand l'utilisateur active le toggle "Tout manuel" pour un établissement dans l'onglet Clés de répartition, les montants calculés automatiquement (prorata) sont d'abord sauvegardés en BDD via la nouvelle procédure batchSetMontantsEtab, puis le flag modeManuel est activé. L'établissement part donc avec les valeurs précalculées comme base de saisie manuelle. 2026-06-11 09:17:27 +00:00
Manus
60fd2b891e Checkpoint: Suppression des 6 lignes parasites en BDD (Infogérance, Sécurité, Applicatifs HEP, Applicatifs SMR, Téléphonie, TOTAL) + filtre PARASITE_PATTERNS dans handleFileImport pour empêcher leur réinsertion lors d'un import Excel. 2026-06-11 08:48:10 +00:00
Manus
eb1684f04c Checkpoint: Quand un établissement est en mode tout manuel, toutes ses cellules (postes en calcul automatique inclus) sont désormais cliquables et éditables. Fond ambre pour distinguer visuellement le mode tout manuel du mode manuel poste par poste (bleu). Les cellules sans saisie affichent "cliquer" en ambre. 2026-06-11 08:06:09 +00:00
Manus
0e1cab9f6c Checkpoint: Bug corrigé : ajout contrainte UNIQUE (annee, etablissementCode) sur opex_bases_repartition + déduplication des lignes existantes — le clic ne crée plus de doublon. Nouvelle fonctionnalité : colonne "Tout manuel" (toggle) dans l'onglet Clés de répartition — quand activé pour un établissement, le calcul prorata est ignoré et seuls les montants saisis manuellement dans la vue Établissements sont utilisés. Badge ambre visible dans les deux vues. 2026-06-11 07:57:02 +00:00
Manus
cfeb5196db Checkpoint: Corrections des deux lacunes identifiées dans l'onglet "Clés de répartition" de DSI OPEX :
1. Loading/error state : ClesRepartitionView reçoit désormais isLoadingBases et isErrorBases depuis la requête trpc.opex.getBasesRepartition.useQuery(). Affiche un spinner pendant le chargement et un message d'erreur si la requête échoue (au lieu d'un tableau vide trompeur).
2. Import fichier amélioré : détection intelligente de la feuille (mots-clés OPEX/DSI/répartition/base, insensible aux accents), recherche de l'en-tête dans les 15 premières lignes, normalisation des accents pour la détection des colonnes, message d'erreur détaillé avec les colonnes détectées, toast informatif si plusieurs feuilles présentes.
14 tests Vitest passants. 0 erreur TypeScript.
2026-06-11 07:24:34 +00:00
Manus
841ff1bd09 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. 2026-06-11 07:20:24 +00:00
Manus
14a660892a Checkpoint: - Ajout table opex_bases_repartition (11e table) avec 58 bases de répartition 2026 seedées depuis le JSON source
- Ajout procédure tRPC opex.getBasesRepartition et opex.setBaseRepartition
- DsiOpex utilise maintenant les bases de répartition depuis la BDD (priorité BDD > JSON fallback pour 2025)
- Total en bas à droite de la vue établissements affiche totalGlobal (somme postes BDD = 1 257 678 €) au lieu de la somme des arrondis par établissement (1 259 641 €)
- 0 erreur TypeScript
2026-06-11 06:24:31 +00:00
Manus
7d4ac0721d Checkpoint: Seed des données OPEX depuis les fichiers JSON sources vers la base de données MySQL. 33 postes 2025 + 35 postes 2026 + 2030 montants établissements 2026 insérés. Script scripts/seed-opex.mjs créé pour réimport futur. La page DSI OPEX charge désormais les données depuis la BDD sans dépendre du localStorage. 2026-06-10 13:11:32 +00:00
22 changed files with 3935 additions and 30 deletions

29
.env.recette Normal file
View File

@@ -0,0 +1,29 @@
# Environnement de recette — itinova-budget-si
NODE_ENV=production
# Base de données locale MySQL
MYSQL_ROOT_PASSWORD=BudgetSI2027!
MYSQL_DATABASE=budget_si
MYSQL_USER=budget_user
MYSQL_PASSWORD=BudgetSI2027!
DATABASE_URL=mysql://budget_user:BudgetSI2027!@db:3306/budget_si
# JWT
JWT_SECRET=budget-si-recette-jwt-secret-2027
# OAuth Manus (désactivé en recette — auth locale uniquement)
VITE_APP_ID=
OAUTH_SERVER_URL=
VITE_OAUTH_PORTAL_URL=
OWNER_OPEN_ID=
OWNER_NAME=
# Forge API (désactivé en recette)
BUILT_IN_FORGE_API_URL=
BUILT_IN_FORGE_API_KEY=
VITE_FRONTEND_FORGE_API_KEY=
VITE_FRONTEND_FORGE_API_URL=
# Analytics (désactivé en recette)
VITE_ANALYTICS_ENDPOINT=
VITE_ANALYTICS_WEBSITE_ID=

37
Dockerfile Normal file
View File

@@ -0,0 +1,37 @@
FROM node:22-alpine AS builder
WORKDIR /app
# Copier les fichiers de dépendances
COPY package.json pnpm-lock.yaml ./
RUN npm install -g pnpm && pnpm install --frozen-lockfile
# Copier le reste du code
COPY . .
# Build
RUN pnpm build
# ── Image de production ──────────────────────────────────────────────────────
FROM node:22-alpine AS runner
WORKDIR /app
RUN npm install -g pnpm
# Copier les dépendances de production uniquement
COPY package.json pnpm-lock.yaml ./
RUN pnpm install --frozen-lockfile --prod
# Copier le build
COPY --from=builder /app/dist ./dist
# Copier les fichiers de configuration nécessaires au runtime
COPY drizzle ./drizzle
COPY drizzle.config.ts ./
EXPOSE 3000
ENV NODE_ENV=production
CMD ["node", "dist/index.js"]

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,439 @@ function LigneForm({
);
}
// ─── Composant Clés de répartition ──────────────────────────────────────────
interface BaseRow {
annee: number;
etablissementCode: string;
etablissementNom: string | null;
baseRepartition: string;
baseRepartitionHep: string | null;
modeManuel: boolean;
}
interface ClesRepartitionViewProps {
annee: number;
basesRepartitionRaw: BaseRow[];
isLoadingBases: boolean;
isErrorBases: boolean;
setBaseRepartition: ReturnType<typeof trpc.opex.setBaseRepartition.useMutation>;
importBasesRepartition: ReturnType<typeof trpc.opex.importBasesRepartition.useMutation>;
batchSetMontantsEtab: ReturnType<typeof trpc.opex.batchSetMontantsEtab.useMutation>;
etablissementsRecalcules: Array<{ code: string; nom: string; montants: Record<string, number>; total: number }>;
}
function ClesRepartitionView({ annee, basesRepartitionRaw, isLoadingBases, isErrorBases, setBaseRepartition, importBasesRepartition, batchSetMontantsEtab, etablissementsRecalcules }: 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,
modeManuel: row.modeManuel,
});
setEditingCell(null);
}
function toggleModeManuel(row: BaseRow) {
const activating = !row.modeManuel;
if (activating) {
// Avant d'activer le mode manuel, on pré-remplit les montants avec les valeurs
// calculées automatiquement (prorata) pour cet établissement
const etabCalc = etablissementsRecalcules.find(e => e.code === row.etablissementCode);
if (etabCalc && Object.keys(etabCalc.montants).length > 0) {
const montantsASeeder = Object.entries(etabCalc.montants)
.filter(([, v]) => v > 0)
.map(([libellePoste, v]) => ({ libellePoste, montant: String(v) }));
if (montantsASeeder.length > 0) {
batchSetMontantsEtab.mutate(
{ annee, etablissementCode: row.etablissementCode, montants: montantsASeeder },
{
onSettled: () => {
// Activer le flag modeManuel après la sauvegarde des montants
setBaseRepartition.mutate({
annee,
etablissementCode: row.etablissementCode,
etablissementNom: row.etablissementNom,
baseRepartition: parseFloat(row.baseRepartition ?? '0'),
baseRepartitionHep: parseFloat(row.baseRepartitionHep ?? '0'),
modeManuel: true,
});
},
}
);
return;
}
}
}
// Cas normal : désactivation ou activation sans montants à seeder
setBaseRepartition.mutate({
annee,
etablissementCode: row.etablissementCode,
etablissementNom: row.etablissementNom,
baseRepartition: parseFloat(row.baseRepartition ?? '0'),
baseRepartitionHep: parseFloat(row.baseRepartitionHep ?? '0'),
modeManuel: activating,
});
}
async function handleFileImport(file: File) {
try {
const XLSX = await import('xlsx');
const buf = await file.arrayBuffer();
const wb = XLSX.read(buf, { type: 'array' });
// ── Sélection intelligente de la feuille ──────────────────────────────
// Priorité : feuille dont le nom contient OPEX, DSI, répartition ou base
// (insensible à la casse et aux accents). Sinon, première feuille.
const SHEET_KEYWORDS = ['opex', 'dsi', 'repartition', 'répartition', 'base'];
const selectedSheetName =
wb.SheetNames.find(name => {
const lower = name.toLowerCase().normalize('NFD').replace(/[\u0300-\u036f]/g, '');
return SHEET_KEYWORDS.some(kw => lower.includes(kw));
}) ?? wb.SheetNames[0];
const ws = wb.Sheets[selectedSheetName];
// Informer l'utilisateur de la feuille utilisée si le classeur en contient plusieurs
if (wb.SheetNames.length > 1) {
toast.info(`Feuille utilisée : « ${selectedSheetName} »`, {
description: `${wb.SheetNames.length} feuilles disponibles dans le classeur.`,
});
}
const data: string[][] = XLSX.utils.sheet_to_json(ws, { header: 1, defval: '' }) as string[][];
// ── Détection de la ligne d'en-tête ──────────────────────────────────
// Cherche dans les 15 premières lignes une ligne contenant un code établissement
let headerIdx = -1;
for (let i = 0; i < Math.min(data.length, 15); i++) {
const row = data[i].map(c => String(c).toLowerCase().normalize('NFD').replace(/[\u0300-\u036f]/g, ''));
if (row.some(c =>
c.includes('code') ||
c.includes('etablissement') ||
c.includes('etab') ||
c.includes('structure')
)) {
headerIdx = i;
break;
}
}
if (headerIdx === -1) {
toast.error('Format non reconnu', {
description: `Impossible de trouver la ligne d'en-tête dans la feuille « ${selectedSheetName} ». Colonnes attendues : code établissement, base de répartition.`,
});
return;
}
// ── Détection des colonnes ────────────────────────────────────────────
const headers = data[headerIdx].map(c =>
String(c).toLowerCase().normalize('NFD').replace(/[\u0300-\u036f]/g, '')
);
const codeIdx = headers.findIndex(h => h.includes('code'));
const nomIdx = headers.findIndex(h =>
h.includes('nom') || h.includes('etablissement') || h.includes('etab') || h.includes('libelle')
);
// Colonne base standard : contient 'base' ou 'repartition' mais PAS 'hep'
const baseIdx = headers.findIndex(h =>
(h.includes('base') || h.includes('repartition')) && !h.includes('hep')
);
// Colonne base HEP : contient '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. Colonnes détectées : ${headers.filter(Boolean).join(', ') || '(aucune)'}`,
});
return;
}
// ── 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;
})
.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, '').replace(',', '.')) || 0,
baseRepartitionHep: hepIdx >= 0
? parseFloat(String(row[hepIdx]).replace(/[^0-9.,]/g, '').replace(',', '.')) || 0
: 0,
}));
if (importRows.length === 0) {
toast.error('Aucune ligne importée', {
description: 'Le fichier ne contient aucune ligne de données valide après la ligne d\'en-tête.',
});
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) + ' €';
}
if (isLoadingBases) {
return (
<div className="flex flex-col items-center justify-center py-20 gap-3 text-muted-foreground">
<Loader2 className="w-8 h-8 animate-spin text-primary" />
<p className="text-sm">Chargement des bases de répartition</p>
</div>
);
}
if (isErrorBases) {
return (
<div className="flex flex-col items-center justify-center py-20 gap-3">
<AlertCircle className="w-8 h-8 text-destructive" />
<p className="text-sm font-semibold text-destructive">Erreur lors du chargement des bases de répartition</p>
<p className="text-xs text-muted-foreground">Vérifiez votre connexion et rechargez la page.</p>
</div>
);
}
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>
<th className="px-4 py-3 text-center font-semibold text-foreground text-xs uppercase tracking-wide w-36" title="Si activé, tous les montants OPEX de cet établissement sont saisis manuellement (pas de calcul prorata)">
Tout manuel
</th>
</tr>
</thead>
<tbody>
{rows.length === 0 && (
<tr>
<td colSpan={5} 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 ${row.modeManuel ? 'bg-amber-50/60' : 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}
{row.modeManuel && (
<span className="ml-2 inline-flex items-center px-1.5 py-0.5 rounded text-xs font-semibold bg-amber-100 text-amber-700 border border-amber-200">
Manuel
</span>
)}
</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>
{/* Colonne Mode Manuel */}
<td className="px-4 py-2.5 text-center">
<button
onClick={() => toggleModeManuel(row)}
disabled={setBaseRepartition.isPending}
title={row.modeManuel
? 'Mode manuel activé — cliquer pour revenir au calcul prorata automatique'
: 'Cliquer pour activer le mode tout manuel (montants OPEX saisis manuellement pour cet établissement)'}
className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-primary/30 disabled:opacity-50 ${
row.modeManuel ? 'bg-amber-500' : 'bg-muted-foreground/30'
}`}
>
<span
className={`inline-block h-3.5 w-3.5 rounded-full bg-white shadow transition-transform ${
row.modeManuel ? 'translate-x-4.5' : 'translate-x-0.5'
}`}
/>
</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
{basesRepartitionRaw.filter(r => r.modeManuel).length > 0 && (
<span className="ml-2 text-xs font-normal text-amber-600">
({basesRepartitionRaw.filter(r => r.modeManuel).length} en mode manuel)
</span>
)}
</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>
<td className="px-4 py-3 text-center text-xs text-muted-foreground">
{basesRepartitionRaw.filter(r => r.modeManuel).length > 0
? `${basesRepartitionRaw.filter(r => r.modeManuel).length} actif${basesRepartitionRaw.filter(r => r.modeManuel).length > 1 ? 's' : ''}`
: '—'}
</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() {
@@ -386,6 +819,8 @@ export default function DsiOpex() {
const { data: postesRaw, isLoading: loadingPostes } = trpc.opex.getPostes.useQuery({ annee });
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)
const { data: basesRepartitionRaw, isLoading: isLoadingBases, isError: isErrorBases } = trpc.opex.getBasesRepartition.useQuery({ annee });
// ── Mutations tRPC ─────────────────────────────────────────────────────────
const upsertPoste = trpc.opex.upsertPoste.useMutation({
@@ -408,6 +843,18 @@ 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 }),
});
const batchSetMontantsEtab = trpc.opex.batchSetMontantsEtab.useMutation({
onSuccess: () => utils.opex.getMontantsEtab.invalidate({ annee }),
onError: (err) => toast.error('Erreur sauvegarde montants', { description: err.message }),
});
// ── Mapper les données BDD vers le format interne ──────────────────────────
const lignes: LigneOpex[] = useMemo(() => {
@@ -685,24 +1132,54 @@ export default function DsiOpex() {
);
// Vue établissements : recalcul dynamique depuis les postes éditables
// Les bases de répartition viennent de la BDD (table opex_bases_repartition)
// Fallback sur le JSON source si la BDD n'a pas de données pour cette année
const etablissementsRecalcules = useMemo(() => {
const srcData = getOpexDataForAnnee(annee);
const totalBase = srcData.etablissements.reduce((s, e) => s + e.base_repartition, 0);
if (totalBase === 0) return srcData.etablissements.map(e => ({ ...e, montants: {} as Record<string, number>, total: 0 }));
// Priorité : BDD > JSON source
const basesFromBdd = basesRepartitionRaw && basesRepartitionRaw.length > 0
? basesRepartitionRaw.map(b => ({
code: b.etablissementCode,
nom: b.etablissementNom ?? b.etablissementCode,
base_repartition: parseFloat(b.baseRepartition ?? '0') || 0,
base_repartition_hep: parseFloat(b.baseRepartitionHep ?? '0') || 0,
modeManuel: b.modeManuel ?? false,
}))
: 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
modeManuel: false,
}));
return srcData.etablissements.map(etab => {
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) {
if (etab.modeManuel) {
// Mode tout manuel : utiliser uniquement les montants saisis manuellement,
// sans aucun calcul prorata — si pas de saisie, le montant est 0
montant = etabOverrides[ligne.libelle] ?? 0;
} else 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;
}
@@ -712,7 +1189,7 @@ export default function DsiOpex() {
return { ...etab, montants: montantsRecalcules, total: totalEtab };
});
}, [lignes, montantsManuelEtab, annee]);
}, [lignes, montantsManuelEtab, annee, basesRepartitionRaw]);
const filteredEtabs = useMemo(() => {
return etablissementsRecalcules.filter(e => {
@@ -726,6 +1203,8 @@ export default function DsiOpex() {
const arr = [...filteredEtabs];
if (sortCol === 'total') arr.sort((a, b) => sortDir === 'asc' ? a.total - b.total : b.total - a.total);
else if (sortCol === 'nom') arr.sort((a, b) => sortDir === 'asc' ? a.nom.localeCompare(b.nom) : b.nom.localeCompare(a.nom));
else if (sortCol === 'code') arr.sort((a, b) => sortDir === 'asc' ? a.code.localeCompare(b.code) : b.code.localeCompare(a.code));
else arr.sort((a, b) => a.code.localeCompare(b.code)); // tri par défaut : code croissant
return arr;
}, [filteredEtabs, sortCol, sortDir]);
@@ -962,6 +1441,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)}
@@ -1287,7 +1775,9 @@ export default function DsiOpex() {
<table className="text-xs border-separate border-spacing-0" style={{ minWidth: 'max-content', width: '100%' }}>
<thead>
<tr className="border-b border-border bg-muted/40">
<th className="text-left px-3 py-3 font-medium text-muted-foreground" style={{ position: 'sticky', left: 0, top: 0, zIndex: 40, background: 'oklch(0.94 0.005 80)', boxShadow: '2px 0 4px -1px rgba(0,0,0,0.15)', width: '110px', minWidth: '110px', maxWidth: '110px' }}>Code</th>
<th className="text-left px-3 py-3 font-medium text-muted-foreground" style={{ position: 'sticky', left: 0, top: 0, zIndex: 40, background: 'oklch(0.94 0.005 80)', boxShadow: '2px 0 4px -1px rgba(0,0,0,0.15)', width: '110px', minWidth: '110px', maxWidth: '110px' }}>
<SortBtn col="code" label="Code" />
</th>
<th className="text-left px-3 py-3 font-medium text-muted-foreground" style={{ position: 'sticky', left: '110px', top: 0, zIndex: 40, background: 'oklch(0.94 0.005 80)', boxShadow: '2px 0 8px -2px rgba(0,0,0,0.2)', width: '220px', minWidth: '220px', maxWidth: '220px' }}>
<SortBtn col="nom" label="Établissement" />
</th>
@@ -1326,22 +1816,33 @@ export default function DsiOpex() {
</tr>
</thead>
<tbody>
{sortedEtabs.map((etab, idx) => (
<tr key={etab.code} className={`border-b border-border/50 hover:bg-muted/20 transition-colors ${idx % 2 === 0 ? '' : 'bg-muted/5'}`}>
<td className="px-3 py-2 whitespace-nowrap" style={{ position: 'sticky', left: 0, zIndex: 20, background: idx % 2 === 0 ? 'oklch(1 0 0)' : 'oklch(0.97 0.003 80)', boxShadow: '2px 0 4px -1px rgba(0,0,0,0.12)', width: '110px', minWidth: '110px', maxWidth: '110px' }}>
{sortedEtabs.map((etab, idx) => {
const rowBg = etab.modeManuel ? 'oklch(0.98 0.02 80)' : (idx % 2 === 0 ? 'oklch(1 0 0)' : 'oklch(0.97 0.003 80)');
return (
<tr key={etab.code} className={`border-b border-border/50 hover:bg-muted/20 transition-colors ${etab.modeManuel ? 'bg-amber-50/40' : idx % 2 === 0 ? '' : 'bg-muted/5'}`}>
<td className="px-3 py-2 whitespace-nowrap" style={{ position: 'sticky', left: 0, zIndex: 20, background: rowBg, boxShadow: '2px 0 4px -1px rgba(0,0,0,0.12)', width: '110px', minWidth: '110px', maxWidth: '110px' }}>
<span className="font-mono bg-muted px-1.5 py-0.5 rounded text-muted-foreground">{etab.code}</span>
</td>
<td className="px-3 py-2 font-medium text-foreground whitespace-nowrap overflow-hidden text-ellipsis" style={{ position: 'sticky', left: '110px', zIndex: 20, background: idx % 2 === 0 ? 'oklch(1 0 0)' : 'oklch(0.97 0.003 80)', boxShadow: '2px 0 8px -2px rgba(0,0,0,0.2)', width: '220px', minWidth: '220px', maxWidth: '220px' }} title={etab.nom}>{etab.nom}</td>
<td className="px-3 py-2 font-medium text-foreground whitespace-nowrap overflow-hidden text-ellipsis" style={{ position: 'sticky', left: '110px', zIndex: 20, background: rowBg, boxShadow: '2px 0 8px -2px rgba(0,0,0,0.2)', width: '220px', minWidth: '220px', maxWidth: '220px' }} title={etab.nom}>
{etab.nom}
{etab.modeManuel && (
<span className="ml-1.5 inline-flex items-center px-1 py-0.5 rounded text-[10px] font-semibold bg-amber-100 text-amber-700 border border-amber-200" title="Mode tout manuel activé — montants saisis manuellement, pas de calcul prorata">
Manuel
</span>
)}
</td>
{lignes.map(ligne => {
const isManuel = ligne.mode_ventilation === 'Manuel' || ligne.isCustom;
// En mode tout manuel pour cet établissement, toutes les cellules sont éditables
const isCellEditable = etab.modeManuel || isManuel;
const montant = etab.montants[ligne.libelle] ?? 0;
const cellKey = `${etab.code}|${ligne.libelle}`;
const isEditing = inlineEditKey === cellKey;
const hasOverride = montantsManuelEtab[etab.code]?.[ligne.libelle] !== undefined;
if (isManuel && isEditing) {
if (isCellEditable && isEditing) {
return (
<td key={ligne.id} className="px-1 py-1 border border-blue-400 bg-blue-50 min-w-[90px]">
<td key={ligne.id} className="px-1 py-1 border border-amber-400 bg-amber-50 min-w-[90px]">
<input
autoFocus
type="number"
@@ -1355,7 +1856,7 @@ export default function DsiOpex() {
if (e.key === 'Enter') handleInlineEditCommit(etab.code, ligne.libelle);
if (e.key === 'Escape') handleInlineEditCancel();
}}
className="w-full text-right text-xs tabular-nums px-1 py-0.5 border-0 bg-transparent focus:outline-none text-blue-800 font-medium"
className="w-full text-right text-xs tabular-nums px-1 py-0.5 border-0 bg-transparent focus:outline-none text-amber-800 font-medium"
style={{ minWidth: '70px' }}
/>
</td>
@@ -1365,20 +1866,30 @@ export default function DsiOpex() {
return (
<td
key={ligne.id}
onClick={() => isManuel && !isValidated && handleInlineEditStart(etab.code, ligne.libelle, montant)}
onClick={() => isCellEditable && !isValidated && handleInlineEditStart(etab.code, ligne.libelle, montant)}
className={`px-2 py-2 text-right tabular-nums transition-colors ${
isManuel
? `border border-blue-200 bg-blue-50/30 text-blue-800 ${
!isValidated ? 'cursor-pointer hover:bg-blue-100/60 hover:border-blue-400' : ''
} ${hasOverride ? 'font-semibold' : ''}`
: 'border border-emerald-200 bg-emerald-50/20 text-emerald-800'
etab.modeManuel
? `border border-amber-200 bg-amber-50/30 text-amber-800 ${
!isValidated ? 'cursor-pointer hover:bg-amber-100/60 hover:border-amber-400' : ''
} ${hasOverride ? 'font-semibold' : 'opacity-60'}`
: isManuel
? `border border-blue-200 bg-blue-50/30 text-blue-800 ${
!isValidated ? 'cursor-pointer hover:bg-blue-100/60 hover:border-blue-400' : ''
} ${hasOverride ? 'font-semibold' : ''}`
: 'border border-emerald-200 bg-emerald-50/20 text-emerald-800'
}`}
title={isManuel && !isValidated ? 'Cliquer pour saisir le montant' : undefined}
title={
etab.modeManuel && !isValidated
? 'Cliquer pour saisir le montant (mode tout manuel)'
: isManuel && !isValidated
? 'Cliquer pour saisir le montant'
: undefined
}
>
{montant > 0
? new Intl.NumberFormat('fr-FR', { maximumFractionDigits: 0 }).format(montant) + ' €'
: isManuel && !isValidated
? <span className="text-blue-300 text-[10px]">cliquer</span>
: isCellEditable && !isValidated
? <span className={etab.modeManuel ? 'text-amber-300 text-[10px]' : 'text-blue-300 text-[10px]'}>cliquer</span>
: <span className="text-muted-foreground"></span>}
</td>
);
@@ -1389,7 +1900,8 @@ export default function DsiOpex() {
</span>
</td>
</tr>
))}
);
})}
</tbody>
<tfoot>
<tr className="bg-muted/40 border-t-2 border-border">
@@ -1398,7 +1910,8 @@ export default function DsiOpex() {
</td>
{lignes.map(ligne => {
const isManuel = ligne.mode_ventilation === 'Manuel' || ligne.isCustom;
const totalColonne = sortedEtabs.reduce((s, e) => s + (e.montants[ligne.libelle] ?? 0), 0);
// Utiliser le montant du poste (source BDD) pour éviter les écarts d'arrondis
const montantPoste = ligne.montant;
return (
<td
key={ligne.id}
@@ -1406,14 +1919,15 @@ export default function DsiOpex() {
isManuel ? 'text-blue-700 bg-blue-50/40' : 'text-emerald-700 bg-emerald-50/30'
}`}
>
{totalColonne > 0
? new Intl.NumberFormat('fr-FR', { maximumFractionDigits: 0 }).format(totalColonne) + ' €'
{montantPoste > 0
? new Intl.NumberFormat('fr-FR', { maximumFractionDigits: 0 }).format(montantPoste) + ' €'
: '—'}
</td>
);
})}
<td className="px-3 py-3 text-right font-bold text-orange-600 text-sm tabular-nums" style={{ position: 'sticky', right: 0, zIndex: 20, background: 'oklch(0.94 0.005 80)', boxShadow: '-3px 0 8px -2px rgba(0,0,0,0.18)', width: '120px', minWidth: '120px' }}>
{formatEuros(sortedEtabs.reduce((s, e) => s + e.total, 0))}
{/* Total depuis les montants BDD — pas la somme des arrondis par établissement */}
{formatEuros(totalGlobal)}
</td>
</tr>
</tfoot>
@@ -1422,6 +1936,19 @@ export default function DsiOpex() {
</div>
</div>
)}
{/* Vue Clés de répartition */}
{viewMode === 'cles_repartition' && (
<ClesRepartitionView
annee={annee}
basesRepartitionRaw={basesRepartitionRaw ?? []}
isLoadingBases={isLoadingBases}
isErrorBases={isErrorBases}
setBaseRepartition={setBaseRepartition}
importBasesRepartition={importBasesRepartition}
batchSetMontantsEtab={batchSetMontantsEtab}
etablissementsRecalcules={etablissementsRecalcules}
/>
)}
</div>
</main>

68
docker-compose.yml Normal file
View File

@@ -0,0 +1,68 @@
version: "3.9"
services:
app:
build: .
container_name: itinova-budget-si-app
restart: unless-stopped
ports:
- "3042:3000"
environment:
- NODE_ENV=production
- DATABASE_URL=${DATABASE_URL}
- JWT_SECRET=${JWT_SECRET}
- VITE_APP_ID=${VITE_APP_ID}
- OAUTH_SERVER_URL=${OAUTH_SERVER_URL}
- VITE_OAUTH_PORTAL_URL=${VITE_OAUTH_PORTAL_URL}
- OWNER_OPEN_ID=${OWNER_OPEN_ID}
- OWNER_NAME=${OWNER_NAME}
- BUILT_IN_FORGE_API_URL=${BUILT_IN_FORGE_API_URL}
- BUILT_IN_FORGE_API_KEY=${BUILT_IN_FORGE_API_KEY}
- VITE_FRONTEND_FORGE_API_KEY=${VITE_FRONTEND_FORGE_API_KEY}
- VITE_FRONTEND_FORGE_API_URL=${VITE_FRONTEND_FORGE_API_URL}
- VITE_ANALYTICS_ENDPOINT=${VITE_ANALYTICS_ENDPOINT}
- VITE_ANALYTICS_WEBSITE_ID=${VITE_ANALYTICS_WEBSITE_ID}
depends_on:
db:
condition: service_healthy
networks:
- budget-net
db:
image: mysql:8.0
container_name: itinova-budget-si-db
restart: unless-stopped
environment:
MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD:-BudgetSI2027!}
MYSQL_DATABASE: ${MYSQL_DATABASE:-budget_si}
MYSQL_USER: ${MYSQL_USER:-budget_user}
MYSQL_PASSWORD: ${MYSQL_PASSWORD:-BudgetSI2027!}
volumes:
- db_data:/var/lib/mysql
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "root", "-p${MYSQL_ROOT_PASSWORD:-BudgetSI2027!}"]
interval: 10s
timeout: 5s
retries: 5
networks:
- budget-net
nginx:
image: nginx:alpine
container_name: itinova-budget-si-nginx
restart: unless-stopped
ports:
- "3043:80"
volumes:
- ./nginx.conf:/etc/nginx/conf.d/default.conf:ro
depends_on:
- app
networks:
- budget-net
volumes:
db_data:
networks:
budget-net:
driver: bridge

View File

@@ -0,0 +1,9 @@
CREATE TABLE `opex_bases_repartition` (
`id` int AUTO_INCREMENT NOT NULL,
`annee` int NOT NULL,
`etablissementCode` varchar(50) NOT NULL,
`etablissementNom` varchar(255),
`baseRepartition` decimal(20,6) NOT NULL,
`createdAt` timestamp NOT NULL DEFAULT (now()),
CONSTRAINT `opex_bases_repartition_id` PRIMARY KEY(`id`)
);

View File

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

View File

@@ -0,0 +1,2 @@
ALTER TABLE `opex_bases_repartition` ADD `modeManuel` boolean DEFAULT false NOT NULL;--> statement-breakpoint
ALTER TABLE `opex_bases_repartition` ADD CONSTRAINT `opex_bases_repartition_annee_etab_idx` UNIQUE(`annee`,`etablissementCode`);

View File

@@ -0,0 +1,864 @@
{
"version": "5",
"dialect": "mysql",
"id": "7cfbd553-42c7-4195-8927-d7db39cd57d6",
"prevId": "973efedb-ae3f-4e9d-b1e4-fb7ab4115377",
"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
},
"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

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

@@ -0,0 +1,889 @@
{
"version": "5",
"dialect": "mysql",
"id": "44abeed3-e7bf-47cd-a29c-59a27bf57f1a",
"prevId": "baff50d1-531e-4ba7-a82a-6f9c64aa1186",
"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'"
},
"modeManuel": {
"name": "modeManuel",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"createdAt": {
"name": "createdAt",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(now())"
}
},
"indexes": {
"opex_bases_repartition_annee_etab_idx": {
"name": "opex_bases_repartition_annee_etab_idx",
"columns": [
"annee",
"etablissementCode"
],
"isUnique": true
}
},
"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

@@ -8,6 +8,27 @@
"when": 1781092850222,
"tag": "0000_fat_falcon",
"breakpoints": true
},
{
"idx": 1,
"version": "5",
"when": 1781158775157,
"tag": "0001_flaky_kitty_pryde",
"breakpoints": true
},
{
"idx": 2,
"version": "5",
"when": 1781161698458,
"tag": "0002_faithful_famine",
"breakpoints": true
},
{
"idx": 3,
"version": "5",
"when": 1781163262493,
"tag": "0003_tense_havok",
"breakpoints": true
}
]
}

View File

@@ -6,6 +6,7 @@ import {
mysqlTable,
text,
timestamp,
uniqueIndex,
varchar,
} from "drizzle-orm/mysql-core";
@@ -160,3 +161,26 @@ export const capexLignes = mysqlTable("capex_lignes", {
export type CapexLigne = typeof capexLignes.$inferSelect;
export type InsertCapexLigne = typeof capexLignes.$inferInsert;
// ───────────────────────────────────────────────────────────────────────────────
// OPEX — Bases de répartition par établissement et par année
// (charges de classe 6 utilisées pour le prorata)
// ───────────────────────────────────────────────────────────────────────────────
export const opexBasesRepartition = mysqlTable("opex_bases_repartition", {
id: int("id").autoincrement().primaryKey(),
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"),
/** Si true, tous les montants OPEX de cet établissement sont saisis manuellement (pas de calcul prorata) */
modeManuel: boolean("modeManuel").default(false).notNull(),
createdAt: timestamp("createdAt").defaultNow().notNull(),
}, (t) => ({
uniqAnneeEtab: uniqueIndex("opex_bases_repartition_annee_etab_idx").on(t.annee, t.etablissementCode),
}));
export type OpexBaseRepartition = typeof opexBasesRepartition.$inferSelect;
export type InsertOpexBaseRepartition = typeof opexBasesRepartition.$inferInsert;

20
nginx.conf Normal file
View File

@@ -0,0 +1,20 @@
server {
listen 80;
server_name _;
client_max_body_size 50M;
location / {
proxy_pass http://app:3000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_cache_bypass $http_upgrade;
proxy_read_timeout 300s;
proxy_connect_timeout 75s;
}
}

View File

@@ -0,0 +1,51 @@
import { createConnection } from 'mysql2/promise';
import { config } from 'dotenv';
config();
const conn = await createConnection(process.env.DATABASE_URL);
// Libellés parasites connus (noms de catégories ou lignes de synthèse)
const PARASITES_KEYWORDS = [
'Infogérance', 'Infogerance', 'Sécurité', 'Securite',
'Téléphonie', 'Telephonie',
'App global', 'App HEP', 'App SMR',
'Applicatifs', // commence par
'TOTAL', 'Total',
'Ventilation', 'Répartition', 'Repartition',
'Nouveautés', 'Nouveautes',
];
// 1. Lister les lignes parasites
const [rows] = await conn.execute(
`SELECT id, annee, etablissementCode, etablissementNom FROM opex_bases_repartition ORDER BY annee, etablissementCode`
);
console.log('=== Toutes les lignes opex_bases_repartition ===');
for (const r of rows) {
const code = r.etablissementCode ?? '';
const nom = r.etablissementNom ?? '';
const isParasite = PARASITES_KEYWORDS.some(k =>
code.toLowerCase().includes(k.toLowerCase()) ||
nom.toLowerCase().includes(k.toLowerCase())
) || /^(total|ventilation|répartition|applicatifs)/i.test(code.trim());
if (isParasite) {
console.log(` PARASITE id=${r.id} annee=${r.annee} code="${code}" nom="${nom}"`);
}
}
// 2. Supprimer les parasites
const [delResult] = await conn.execute(
`DELETE FROM opex_bases_repartition
WHERE etablissementCode REGEXP '^(Infog|S.curit|T.l.phonie|App global|App HEP|App SMR|Applicatifs|TOTAL|Total|Ventilation|R.partition|Nouveaut)'
OR etablissementNom REGEXP '^(Infog|S.curit|T.l.phonie|App global|App HEP|App SMR|Applicatifs|TOTAL|Total|Ventilation|R.partition|Nouveaut)'`
);
console.log(`\nSupprimé ${delResult.affectedRows} lignes parasites.`);
// 3. Vérification finale
const [remaining] = await conn.execute(
`SELECT annee, COUNT(*) as cnt FROM opex_bases_repartition GROUP BY annee ORDER BY annee`
);
console.log('=== Lignes restantes par année ===');
console.table(remaining);
await conn.end();

View File

@@ -0,0 +1,45 @@
import mysql from 'mysql2/promise';
import { readFileSync } from 'fs';
import { fileURLToPath } from 'url';
import { dirname, join } from 'path';
import dotenv from 'dotenv';
const __dirname = dirname(fileURLToPath(import.meta.url));
dotenv.config({ path: join(__dirname, '..', '.env') });
const conn = await mysql.createConnection(process.env.DATABASE_URL);
const [rows] = await conn.execute('SELECT libelle, montant FROM opex_postes WHERE annee = 2026 ORDER BY colIdx');
const data = JSON.parse(readFileSync(join(__dirname, '../client/src/data_opex.json'), 'utf8'));
const libellesJson = new Set(data.postes.map(p => p.libelle));
const libellesBdd = new Set(rows.map(r => r.libelle));
console.log('Nb postes BDD:', rows.length);
console.log('Nb postes JSON:', libellesJson.size);
let totalBdd = 0;
rows.forEach(r => { totalBdd += parseFloat(r.montant || '0'); });
console.log('Total BDD:', totalBdd.toFixed(2));
console.log('\n=== En BDD mais PAS dans JSON ===');
for (const lib of libellesBdd) {
if (!libellesJson.has(lib)) {
const row = rows.find(r => r.libelle === lib);
console.log(' +', lib, ':', row.montant);
}
}
console.log('\n=== Dans JSON mais PAS en BDD ===');
for (const lib of libellesJson) {
if (!libellesBdd.has(lib)) {
console.log(' -', lib);
}
}
console.log('\n=== Tous les postes BDD avec montant ===');
rows.forEach(r => {
const m = parseFloat(r.montant || '0');
if (m > 0) console.log(` ${r.libelle}: ${m.toFixed(2)}`);
});
await conn.end();

View File

@@ -0,0 +1,33 @@
import { createConnection } from 'mysql2/promise';
import { config } from 'dotenv';
config();
const conn = await createConnection(process.env.DATABASE_URL);
// 1. Voir les doublons
const [doublons] = await conn.execute(
'SELECT annee, libelle, COUNT(*) as cnt FROM opex_postes GROUP BY annee, libelle HAVING cnt > 1 ORDER BY cnt DESC LIMIT 30'
);
console.log('=== Doublons (annee, libelle) ===');
console.table(doublons);
// 2. Total par année
const [totaux] = await conn.execute(
'SELECT annee, COUNT(*) as cnt FROM opex_postes GROUP BY annee ORDER BY annee'
);
console.log('=== Total postes par année ===');
console.table(totaux);
// 3. Libellés qui ressemblent à des catégories (parasites)
const [parasites] = await conn.execute(
`SELECT id, annee, libelle, categorie FROM opex_postes
WHERE libelle IN ('Infogérance','Sécurité','Téléphonie','App global','App HEP','App SMR',
'Applicatifs communs','Applicatifs PA','Applicatifs HEP','Applicatifs SMR','Nouveautés','TOTAL',
'Ventilation cout','Ventilation coût')
OR libelle REGEXP '^(total|ventilation|répartition)'
ORDER BY annee, libelle`
);
console.log('=== Libellés parasites en BDD ===');
console.table(parasites);
await conn.end();

View File

@@ -0,0 +1,25 @@
import { createConnection } from 'mysql2/promise';
import { config } from 'dotenv';
config();
const conn = await createConnection(process.env.DATABASE_URL);
// Lister toutes les lignes dont le code n'est pas un code établissement standard (7 chiffres + 2 lettres)
const [rows] = await conn.execute(
`SELECT id, annee, etablissementCode, etablissementNom, baseRepartition
FROM opex_bases_repartition
ORDER BY etablissementCode`
);
console.log('=== Codes non-standard (parasites potentiels) ===');
for (const r of rows) {
const code = r.etablissementCode ?? '';
// Code standard = 7 chiffres + 2 lettres majuscules (ex: 1001BPT)
const isStandard = /^\d{4,7}[A-Z]{2,3}$/.test(code);
if (!isStandard) {
console.log(`id=${r.id} annee=${r.annee} code="${code}" nom="${r.etablissementNom}" base=${r.baseRepartition}`);
}
}
console.log(`\nTotal lignes: ${rows.length}`);
await conn.end();

106
scripts/seed-opex-bases.mjs Normal file
View File

@@ -0,0 +1,106 @@
/**
* 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';
import { fileURLToPath } from 'url';
import { dirname, join } from 'path';
import dotenv from 'dotenv';
const __dirname = dirname(fileURLToPath(import.meta.url));
dotenv.config({ path: join(__dirname, '..', '.env') });
const conn = await mysql.createConnection(process.env.DATABASE_URL);
// ── 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`);
} else {
await conn.execute('DELETE FROM opex_bases_repartition WHERE annee = ?', [annee]);
const values = etabs.map(e => [
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, 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 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('\n✅ Seed bases de répartition terminé.');

139
scripts/seed-opex.mjs Normal file
View File

@@ -0,0 +1,139 @@
/**
* Script de seed : migration des données OPEX 2025 et 2026
* depuis les fichiers JSON sources vers la base de données MySQL.
*
* Usage : node scripts/seed-opex.mjs
*/
import { createRequire } from 'module';
import { readFileSync } from 'fs';
import { resolve, dirname } from 'path';
import { fileURLToPath } from 'url';
import mysql from 'mysql2/promise';
import dotenv from 'dotenv';
const __dirname = dirname(fileURLToPath(import.meta.url));
const projectRoot = resolve(__dirname, '..');
// Charger les variables d'environnement
dotenv.config({ path: resolve(projectRoot, '.env') });
const DATABASE_URL = process.env.DATABASE_URL;
if (!DATABASE_URL) {
console.error('❌ DATABASE_URL manquant dans .env');
process.exit(1);
}
// Lire les fichiers JSON sources
const data2025 = JSON.parse(readFileSync(resolve(projectRoot, 'client/src/data_opex_2025.json'), 'utf-8'));
const data2026 = JSON.parse(readFileSync(resolve(projectRoot, 'client/src/data_opex.json'), 'utf-8'));
async function seedOpex(conn, data) {
const annee = data.annee;
const postes = data.postes;
const etablissements = data.etablissements; // liste ou vide
console.log(`\n📅 Migration OPEX ${annee}${postes.length} postes, ${etablissements?.length ?? 0} établissements`);
// 1. Vérifier si des données existent déjà pour cette année
const [existing] = await conn.execute(
'SELECT COUNT(*) as cnt FROM opex_postes WHERE annee = ?',
[annee]
);
const count = existing[0].cnt;
if (count > 0) {
console.log(` ⚠️ ${count} postes déjà présents pour ${annee} — suppression et réimport...`);
await conn.execute('DELETE FROM opex_montants_etab WHERE annee = ?', [annee]);
await conn.execute('DELETE FROM opex_postes WHERE annee = ?', [annee]);
}
// 2. Insérer les postes
const montantKey = `montant_previsionnel_${annee}`;
let nbPostesInseres = 0;
for (const poste of postes) {
const montant = poste[montantKey] ?? poste.montant_previsionnel ?? null;
await conn.execute(
`INSERT INTO opex_postes
(annee, colIdx, libelle, libelleCourt, libelleDetail, fournisseur, categorie,
type, facturation, modeVentilation, compte, detail, budgetN1, montant, isCustom)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0)`,
[
annee,
poste.col_idx ?? 0,
poste.libelle ?? '',
poste.libelle_court ?? null,
poste.libelle_detail ?? null,
poste.fournisseur ?? null,
poste.categorie ?? null,
poste.type ?? null,
poste.facturation ?? null,
poste.mode_ventilation ?? 'Prorata C 6',
poste.compte ?? null,
poste.detail ?? null,
poste.budget_n1 ?? null,
montant,
]
);
nbPostesInseres++;
}
console.log(`${nbPostesInseres} postes insérés`);
// 3. Insérer les montants par établissement (si disponibles) — batch INSERT
if (Array.isArray(etablissements) && etablissements.length > 0) {
const rows = [];
for (const etab of etablissements) {
const code = etab.code;
const montants = etab.montants ?? {};
for (const [libellePoste, montantVal] of Object.entries(montants)) {
if (montantVal === null || montantVal === undefined) continue;
rows.push([annee, code, libellePoste, montantVal]);
}
}
// Insérer par lots de 500 pour éviter les limites MySQL
const BATCH_SIZE = 500;
let nbMontants = 0;
for (let i = 0; i < rows.length; i += BATCH_SIZE) {
const batch = rows.slice(i, i + BATCH_SIZE);
const placeholders = batch.map(() => '(?, ?, ?, ?)').join(', ');
const values = batch.flat();
await conn.execute(
`INSERT INTO opex_montants_etab (annee, etablissementCode, libellePoste, montant)
VALUES ${placeholders}`,
values
);
nbMontants += batch.length;
}
console.log(`${nbMontants} montants établissements insérés`);
} else {
console.log(` Pas de montants par établissement pour ${annee} (seront calculés à la volée)`);
}
}
async function main() {
console.log('🚀 Démarrage du seed OPEX...');
console.log(`📡 Connexion à la base de données...`);
const conn = await mysql.createConnection(DATABASE_URL);
try {
await seedOpex(conn, data2025);
await seedOpex(conn, data2026);
// Résumé final
const [total] = await conn.execute('SELECT COUNT(*) as cnt FROM opex_postes');
const [totalMontants] = await conn.execute('SELECT COUNT(*) as cnt FROM opex_montants_etab');
console.log(`\n🎉 Seed terminé !`);
console.log(` Total postes en BDD : ${total[0].cnt}`);
console.log(` Total montants établissements : ${totalMontants[0].cnt}`);
} finally {
await conn.end();
}
}
main().catch(err => {
console.error('❌ Erreur lors du seed :', err.message);
process.exit(1);
});

View File

@@ -10,6 +10,7 @@ import {
InsertUser,
inventaireMeta,
inventairePostes,
opexBasesRepartition,
opexMontantsEtab,
opexPostes,
opexValidated,
@@ -326,3 +327,65 @@ export async function insertCapexLignes(rows: InsertCapexLigne[]) {
await db.insert(capexLignes).values(rows.slice(i, i + 100));
}
}
// ── OPEX Bases de répartition ────────────────────────────────────────────────
export async function getOpexBasesRepartition(annee: number) {
const db = await getDb();
if (!db) throw new Error("Database not available");
return db
.select()
.from(opexBasesRepartition)
.where(eq(opexBasesRepartition.annee, annee));
}
export async function upsertOpexBaseRepartition(input: {
annee: number;
etablissementCode: string;
etablissementNom?: string | null;
baseRepartition: number;
baseRepartitionHep?: number;
modeManuel?: boolean;
}) {
const db = await getDb();
if (!db) throw new Error("Database not available");
await db
.insert(opexBasesRepartition)
.values({
annee: input.annee,
etablissementCode: input.etablissementCode,
etablissementNom: input.etablissementNom ?? null,
baseRepartition: String(input.baseRepartition),
baseRepartitionHep: String(input.baseRepartitionHep ?? 0),
modeManuel: input.modeManuel ?? false,
})
.onDuplicateKeyUpdate({
set: {
baseRepartition: String(input.baseRepartition),
baseRepartitionHep: String(input.baseRepartitionHep ?? 0),
etablissementNom: input.etablissementNom ?? null,
modeManuel: input.modeManuel ?? false,
},
});
}
/** 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

@@ -140,6 +140,28 @@ export const appRouter = router({
.input(z.object({ annee: z.number(), etablissementCode: z.string(), libellePoste: z.string(), montant: z.string().nullable() }))
.mutation(async ({ input }) => { await db.setOpexMontantEtab(input as Parameters<typeof db.setOpexMontantEtab>[0]); return { success: true }; }),
// Sauvegarder en masse les montants par établissement (utilisé lors du passage en mode tout manuel)
batchSetMontantsEtab: writeProcedure
.input(z.object({
annee: z.number(),
etablissementCode: z.string(),
montants: z.array(z.object({
libellePoste: z.string(),
montant: z.string(),
}))
}))
.mutation(async ({ input }) => {
for (const m of input.montants) {
await db.setOpexMontantEtab({
annee: input.annee,
etablissementCode: input.etablissementCode,
libellePoste: m.libellePoste,
montant: m.montant,
});
}
return { success: true, count: input.montants.length };
}),
getValidated: protectedProcedure
.input(z.object({ annee: z.number() }))
.query(async ({ input }) => db.getOpexValidated(input.annee)),
@@ -174,6 +196,39 @@ export const appRouter = router({
return { success: true };
}),
// Bases de répartition par établissement (charges classe 6)
getBasesRepartition: protectedProcedure
.input(z.object({ annee: z.number() }))
.query(async ({ input }) => db.getOpexBasesRepartition(input.annee)),
// 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(),
baseRepartitionHep: z.number().optional().default(0),
modeManuel: z.boolean().optional().default(false),
}))
.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() }))

25
todo.md
View File

@@ -40,3 +40,28 @@
- [ ] 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
- [x] Analyser le fichier source pour extraire les colonnes base_repartition et base_repartition_hep
- [x] Ajouter colonne base_repartition_hep dans la table opex_bases_repartition + migration
- [x] Routes tRPC : opex.getBasesRepartition (mise à jour), opex.setBaseRepartition (mise à jour avec hep), opex.importBasesRepartition
- [x] Seeder les données base_repartition_hep 2026 depuis le fichier source
- [x] Créer l'onglet "Clés de répartition" dans DsiOpex avec tableau éditable (base standard + /HEP)
- [x] Import fichier Excel/CSV dans l'onglet Clés de répartition
- [x] Afficher l'info "Année de la base = année OPEX - 2" dans l'onglet
- [x] Brancher le calcul du poste "DUI pôle HEP" sur base_repartition_hep
## Corrections de lacunes (post-checkpoint 841ff1bd)
- [x] Ajouter un vrai loading/error state pour l'onglet « Clés de répartition » basé sur la requête `opex.getBasesRepartition`
- [x] Rendre l'import des clés de répartition compatible avec le classeur source réel : détection intelligente de la bonne feuille (mots-clés OPEX/DSI/répartition/base), normalisation des accents, validation des colonnes attendues avec message d'erreur détaillé
## Bugs et nouvelles fonctionnalités (post-checkpoint cfeb5196)
- [x] Bug : doublon de ligne dans le tableau Clés de répartition au clic (mutation setBaseRepartition provoque un double rendu)
- [x] Fonctionnalité : mode "tout manuel" par établissement dans l'onglet Clés de répartition (toutes les colonnes OPEX de cet établissement saisies manuellement, ignorant le calcul prorata)
## Corrections (post-checkpoint eb1684f0)
- [x] Bug : lignes parasites (catégories Infogérance, Sécurité, TOTAL, etc.) dans la vue Établissements OPEX — suppression en BDD + filtre anti-parasites dans l'import