From 14a660892a045ed6912b4d4e29c9bf2a5047426b Mon Sep 17 00:00:00 2001 From: Manus Date: Thu, 11 Jun 2026 06:24:31 +0000 Subject: [PATCH] =?UTF-8?q?Checkpoint:=20-=20Ajout=20table=20opex=5Fbases?= =?UTF-8?q?=5Frepartition=20(11e=20table)=20avec=2058=20bases=20de=20r?= =?UTF-8?q?=C3=A9partition=202026=20seed=C3=A9es=20depuis=20le=20JSON=20so?= =?UTF-8?q?urce=20-=20Ajout=20proc=C3=A9dure=20tRPC=20opex.getBasesReparti?= =?UTF-8?q?tion=20et=20opex.setBaseRepartition=20-=20DsiOpex=20utilise=20m?= =?UTF-8?q?aintenant=20les=20bases=20de=20r=C3=A9partition=20depuis=20la?= =?UTF-8?q?=20BDD=20(priorit=C3=A9=20BDD=20>=20JSON=20fallback=20pour=2020?= =?UTF-8?q?25)=20-=20Total=20en=20bas=20=C3=A0=20droite=20de=20la=20vue=20?= =?UTF-8?q?=C3=A9tablissements=20affiche=20totalGlobal=20(somme=20postes?= =?UTF-8?q?=20BDD=20=3D=201=20257=20678=20=E2=82=AC)=20au=20lieu=20de=20la?= =?UTF-8?q?=20somme=20des=20arrondis=20par=20=C3=A9tablissement=20(1=20259?= =?UTF-8?q?=20641=20=E2=82=AC)=20-=200=20erreur=20TypeScript?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- client/src/pages/DsiOpex.tsx | 36 +- drizzle/0001_flaky_kitty_pryde.sql | 9 + drizzle/meta/0001_snapshot.json | 864 +++++++++++++++++++++++++++++ drizzle/meta/_journal.json | 7 + drizzle/schema.ts | 16 + scripts/compare-opex-bdd-json.mjs | 45 ++ scripts/seed-opex-bases.mjs | 53 ++ server/db.ts | 35 ++ server/routers.ts | 10 + 9 files changed, 1066 insertions(+), 9 deletions(-) create mode 100644 drizzle/0001_flaky_kitty_pryde.sql create mode 100644 drizzle/meta/0001_snapshot.json create mode 100644 scripts/compare-opex-bdd-json.mjs create mode 100644 scripts/seed-opex-bases.mjs diff --git a/client/src/pages/DsiOpex.tsx b/client/src/pages/DsiOpex.tsx index c701e9c..2272794 100644 --- a/client/src/pages/DsiOpex.tsx +++ b/client/src/pages/DsiOpex.tsx @@ -386,6 +386,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 } = trpc.opex.getBasesRepartition.useQuery({ annee }); // ── Mutations tRPC ───────────────────────────────────────────────────────── const upsertPoste = trpc.opex.upsertPoste.useMutation({ @@ -685,12 +687,26 @@ 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, 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, + })) + : getOpexDataForAnnee(annee).etablissements.map(e => ({ + code: e.code, + nom: e.nom, + base_repartition: e.base_repartition, + })); - return srcData.etablissements.map(etab => { + const totalBase = basesFromBdd.reduce((s, e) => s + e.base_repartition, 0); + if (totalBase === 0) return basesFromBdd.map(e => ({ ...e, montants: {} as Record, total: 0 })); + + return basesFromBdd.map(etab => { const ratio = etab.base_repartition / totalBase; const etabOverrides: Record = montantsManuelEtab[etab.code] ?? {}; @@ -712,7 +728,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 => { @@ -1398,7 +1414,8 @@ export default function DsiOpex() { {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 ( - {totalColonne > 0 - ? new Intl.NumberFormat('fr-FR', { maximumFractionDigits: 0 }).format(totalColonne) + ' €' + {montantPoste > 0 + ? new Intl.NumberFormat('fr-FR', { maximumFractionDigits: 0 }).format(montantPoste) + ' €' : '—'} ); })} - {formatEuros(sortedEtabs.reduce((s, e) => s + e.total, 0))} + {/* Total depuis les montants BDD — pas la somme des arrondis par établissement */} + {formatEuros(totalGlobal)} diff --git a/drizzle/0001_flaky_kitty_pryde.sql b/drizzle/0001_flaky_kitty_pryde.sql new file mode 100644 index 0000000..5b4ad80 --- /dev/null +++ b/drizzle/0001_flaky_kitty_pryde.sql @@ -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`) +); diff --git a/drizzle/meta/0001_snapshot.json b/drizzle/meta/0001_snapshot.json new file mode 100644 index 0000000..72dd23c --- /dev/null +++ b/drizzle/meta/0001_snapshot.json @@ -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": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 5d02d8f..a51efe4 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -8,6 +8,13 @@ "when": 1781092850222, "tag": "0000_fat_falcon", "breakpoints": true + }, + { + "idx": 1, + "version": "5", + "when": 1781158775157, + "tag": "0001_flaky_kitty_pryde", + "breakpoints": true } ] } \ No newline at end of file diff --git a/drizzle/schema.ts b/drizzle/schema.ts index 66fdcc6..3473ed2 100644 --- a/drizzle/schema.ts +++ b/drizzle/schema.ts @@ -160,3 +160,19 @@ 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 }), + baseRepartition: decimal("baseRepartition", { precision: 20, scale: 6 }).notNull(), + createdAt: timestamp("createdAt").defaultNow().notNull(), +}); + +export type OpexBaseRepartition = typeof opexBasesRepartition.$inferSelect; +export type InsertOpexBaseRepartition = typeof opexBasesRepartition.$inferInsert; diff --git a/scripts/compare-opex-bdd-json.mjs b/scripts/compare-opex-bdd-json.mjs new file mode 100644 index 0000000..976c09d --- /dev/null +++ b/scripts/compare-opex-bdd-json.mjs @@ -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(); diff --git a/scripts/seed-opex-bases.mjs b/scripts/seed-opex-bases.mjs new file mode 100644 index 0000000..e074803 --- /dev/null +++ b/scripts/seed-opex-bases.mjs @@ -0,0 +1,53 @@ +/** + * Seed des bases de répartition OPEX (charges classe 6) pour 2025 et 2026 + * depuis les fichiers JSON sources. + */ +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 dataFiles = { + 2025: join(__dirname, '../client/src/data_opex_2025.json'), + 2026: join(__dirname, '../client/src/data_opex.json'), +}; + +for (const [annee, filePath] of Object.entries(dataFiles)) { + const data = JSON.parse(readFileSync(filePath, 'utf8')); + const etabs = data.etablissements || []; + + if (etabs.length === 0) { + console.log(`Année ${annee}: aucun établissement trouvé dans le JSON`); + continue; + } + + // Supprimer les bases existantes pour cette année + await conn.execute('DELETE FROM opex_bases_repartition WHERE annee = ?', [parseInt(annee)]); + + // Insérer en batch + const values = etabs.map(e => [ + parseInt(annee), + e.code, + e.nom || null, + e.base_repartition || 0, + ]); + + if (values.length > 0) { + await conn.query( + 'INSERT INTO opex_bases_repartition (annee, etablissementCode, etablissementNom, baseRepartition) VALUES ?', + [values] + ); + } + + console.log(`Année ${annee}: ${values.length} bases de répartition insérées`); + console.log(` Total base: ${etabs.reduce((s, e) => s + (e.base_repartition || 0), 0).toLocaleString('fr-FR', { maximumFractionDigits: 0 })} €`); +} + +await conn.end(); +console.log('\nSeed bases de répartition terminé.'); diff --git a/server/db.ts b/server/db.ts index fb20878..0a2a8bd 100644 --- a/server/db.ts +++ b/server/db.ts @@ -10,6 +10,7 @@ import { InsertUser, inventaireMeta, inventairePostes, + opexBasesRepartition, opexMontantsEtab, opexPostes, opexValidated, @@ -326,3 +327,37 @@ 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; +}) { + 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), + }) + .onDuplicateKeyUpdate({ + set: { + baseRepartition: String(input.baseRepartition), + etablissementNom: input.etablissementNom ?? null, + }, + }); +} diff --git a/server/routers.ts b/server/routers.ts index 72c8573..2263077 100644 --- a/server/routers.ts +++ b/server/routers.ts @@ -174,6 +174,16 @@ 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 + setBaseRepartition: writeProcedure + .input(z.object({ annee: z.number(), etablissementCode: z.string(), etablissementNom: z.string().optional().nullable(), baseRepartition: z.number() })) + .mutation(async ({ input }) => { await db.upsertOpexBaseRepartition(input); return { success: true }; }), + // Supprimer toutes les lignes OPEX d'une année deleteAll: writeProcedure .input(z.object({ annee: z.number() }))