Checkpoint: Audit de robustesse et maintenabilité : calcul dynamique des tendances OPEX par véritable année N-1, filtrage/validation centralisés des codes établissement, transactions d'import et d'écriture, contraintes uniques CAPEX/OPEX avec déduplication, suppression effective de postes OPEX, nettoyage des gabarits et scripts morts, chargement différé XLSX, documentation métier et 22 tests passants.

This commit is contained in:
Manus
2026-08-17 20:50:33 +00:00
parent 31cdb257fb
commit 615dd46059
29 changed files with 1449 additions and 3307 deletions

View File

@@ -1,4 +1,4 @@
import { and, eq } from "drizzle-orm";
import { and, asc, eq } from "drizzle-orm";
import { drizzle } from "drizzle-orm/mysql2";
import {
capexLignes,
@@ -15,9 +15,9 @@ import {
opexPostes,
opexValidated,
parametresApp,
userEtablissements,
users,
} from "../drizzle/schema";
import { isOpexEtablissementCode } from "../shared/opexValidation";
let _db: ReturnType<typeof drizzle> | null = null;
@@ -97,7 +97,7 @@ export async function updateLastSignedIn(id: number) {
export async function listEtablissements() {
const db = await getDb();
if (!db) return [];
return db.select().from(etablissements);
return db.select().from(etablissements).orderBy(asc(etablissements.code));
}
export async function upsertEtablissement(etab: InsertEtablissement) {
@@ -151,7 +151,8 @@ export async function getOpexPostes(annee: number) {
return db
.select()
.from(opexPostes)
.where(eq(opexPostes.annee, annee));
.where(eq(opexPostes.annee, annee))
.orderBy(asc(opexPostes.colIdx));
}
export async function upsertOpexPoste(poste: InsertOpexPoste) {
@@ -161,7 +162,9 @@ export async function upsertOpexPoste(poste: InsertOpexPoste) {
await db
.update(opexPostes)
.set(poste)
.where(eq(opexPostes.id, poste.id));
// L'année fait partie de la clé fonctionnelle : elle empêche un client
// de modifier un poste d'un autre exercice avec un identifiant forgé.
.where(and(eq(opexPostes.id, poste.id), eq(opexPostes.annee, poste.annee)));
} else {
await db.insert(opexPostes).values(poste);
}
@@ -196,10 +199,29 @@ export async function insertOpexMontantsEtab(rows: InsertOpexMontantEtab[]) {
const db = await getDb();
if (!db) throw new Error("Database not available");
if (rows.length === 0) return;
// Insert par batch de 100
for (let i = 0; i < rows.length; i += 100) {
await db.insert(opexMontantsEtab).values(rows.slice(i, i + 100));
}
await db.transaction(async (tx) => {
// Insert par batch de 100. L'index métier garantit que la même cellule ne
// peut pas être dupliquée lors d'une reprise d'import.
for (let i = 0; i < rows.length; i += 100) {
await tx.insert(opexMontantsEtab).values(rows.slice(i, i + 100));
}
});
}
/** Sauvegarde atomiquement plusieurs valeurs manuelles d'un même établissement. */
export async function setOpexMontantsEtabBatch(rows: InsertOpexMontantEtab[]) {
const db = await getDb();
if (!db) throw new Error("Database not available");
if (rows.length === 0) return;
await db.transaction(async (tx) => {
for (const row of rows) {
await tx
.insert(opexMontantsEtab)
.values(row)
.onDuplicateKeyUpdate({ set: { montant: row.montant } });
}
});
}
export async function deleteOpexPostes(annee: number) {
@@ -208,6 +230,33 @@ export async function deleteOpexPostes(annee: number) {
await db.delete(opexPostes).where(eq(opexPostes.annee, annee));
}
/**
* Supprime un poste OPEX précis et ses surcharges manuelles associées.
* La sélection et les deux suppressions se font dans une transaction afin
* d'empêcher la persistance de montants orphelins.
*/
export async function deleteOpexPoste(annee: number, id: number): Promise<boolean> {
const db = await getDb();
if (!db) throw new Error("Database not available");
return db.transaction(async (tx) => {
const poste = await tx
.select({ libelle: opexPostes.libelle })
.from(opexPostes)
.where(and(eq(opexPostes.id, id), eq(opexPostes.annee, annee)))
.limit(1);
const libelle = poste[0]?.libelle;
if (!libelle) return false;
await tx
.delete(opexMontantsEtab)
.where(and(eq(opexMontantsEtab.annee, annee), eq(opexMontantsEtab.libellePoste, libelle)));
await tx.delete(opexPostes).where(and(eq(opexPostes.id, id), eq(opexPostes.annee, annee)));
return true;
});
}
export async function deleteOpexMontantsEtab(annee: number) {
const db = await getDb();
if (!db) return;
@@ -271,19 +320,20 @@ export async function importInventaire(
) {
const db = await getDb();
if (!db) throw new Error("Database not available");
// Supprimer l'inventaire existant pour cette année
await db.delete(inventairePostes).where(eq(inventairePostes.annee, annee));
// Insérer les nouveaux postes par batch
if (postes.length > 0) {
for (let i = 0; i < postes.length; i += 200) {
await db.insert(inventairePostes).values(postes.slice(i, i + 200));
await db.transaction(async (tx) => {
// L'import remplace l'inventaire annuel en une seule transaction : une
// erreur de lecture ou d'insertion ne laisse jamais l'année partiellement vide.
await tx.delete(inventairePostes).where(eq(inventairePostes.annee, annee));
if (postes.length > 0) {
for (let i = 0; i < postes.length; i += 200) {
await tx.insert(inventairePostes).values(postes.slice(i, i + 200));
}
}
}
// Upsert meta
await db
.insert(inventaireMeta)
.values({ annee, ...meta })
.onDuplicateKeyUpdate({ set: { ...meta, dateImport: new Date() } });
await tx
.insert(inventaireMeta)
.values({ annee, ...meta })
.onDuplicateKeyUpdate({ set: { ...meta, dateImport: new Date() } });
});
}
// ─────────────────────────────────────────────────────────────────────────────
@@ -311,12 +361,14 @@ export async function saveCapexLignes(
) {
const db = await getDb();
if (!db) throw new Error("Database not available");
for (const ligne of lignes) {
await db
.insert(capexLignes)
.values({ annee, etablissementCode, cle: ligne.cle, montant: ligne.montant })
.onDuplicateKeyUpdate({ set: { montant: ligne.montant } });
}
await db.transaction(async (tx) => {
for (const ligne of lignes) {
await tx
.insert(capexLignes)
.values({ annee, etablissementCode, cle: ligne.cle, montant: ligne.montant })
.onDuplicateKeyUpdate({ set: { montant: ligne.montant } });
}
});
}
export async function insertCapexLignes(rows: InsertCapexLigne[]) {
@@ -332,10 +384,15 @@ export async function insertCapexLignes(rows: InsertCapexLigne[]) {
export async function getOpexBasesRepartition(annee: number) {
const db = await getDb();
if (!db) throw new Error("Database not available");
return db
const rows = await db
.select()
.from(opexBasesRepartition)
.where(eq(opexBasesRepartition.annee, annee));
.where(eq(opexBasesRepartition.annee, annee))
.orderBy(asc(opexBasesRepartition.etablissementCode));
// Tolérance de lecture pour les imports historiques : les lignes non
// établissement restent conservées en BDD pour audit mais ne polluent pas la vue.
return rows.filter((row) => isOpexEtablissementCode(row.etablissementCode));
}
export async function upsertOpexBaseRepartition(input: {
@@ -375,17 +432,19 @@ export async function importOpexBasesRepartition(
) {
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),
}))
);
await db.transaction(async (tx) => {
// Remplacement atomique : l'ancienne base n'est supprimée que si la nouvelle
// série complète peut être enregistrée.
await tx.delete(opexBasesRepartition).where(eq(opexBasesRepartition.annee, annee));
if (rows.length === 0) return;
await tx.insert(opexBasesRepartition).values(
rows.map(r => ({
annee,
etablissementCode: r.etablissementCode,
etablissementNom: r.etablissementNom ?? null,
baseRepartition: String(r.baseRepartition),
baseRepartitionHep: String(r.baseRepartitionHep ?? 0),
}))
);
});
}