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:
143
server/db.ts
143
server/db.ts
@@ -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),
|
||||
}))
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user