import { and, asc, eq } from "drizzle-orm"; import { drizzle } from "drizzle-orm/mysql2"; import { capexLignes, etablissements, InsertCapexLigne, InsertEtablissement, InsertOpexMontantEtab, InsertOpexPoste, InsertUser, inventaireMeta, inventairePostes, opexBasesRepartition, opexMontantsEtab, opexPostes, opexValidated, parametresApp, users, } from "../drizzle/schema"; import { isOpexEtablissementCode } from "../shared/opexValidation"; let _db: ReturnType | null = null; // Lazily create the drizzle instance so local tooling can run without a DB. export async function getDb() { if (!_db && process.env.DATABASE_URL) { try { _db = drizzle(process.env.DATABASE_URL); } catch (error) { console.warn("[Database] Failed to connect:", error); _db = null; } } return _db; } // ───────────────────────────────────────────────────────────────────────────── // USERS // ───────────────────────────────────────────────────────────────────────────── export async function getUserByLogin(login: string) { const db = await getDb(); if (!db) return undefined; const result = await db .select() .from(users) .where(eq(users.login, login)) .limit(1); return result.length > 0 ? result[0] : undefined; } export async function getUserById(id: number) { const db = await getDb(); if (!db) return undefined; const result = await db.select().from(users).where(eq(users.id, id)).limit(1); return result.length > 0 ? result[0] : undefined; } export async function createUser(user: InsertUser) { const db = await getDb(); if (!db) throw new Error("Database not available"); const [result] = await db.insert(users).values(user); return result; } export async function updateUser(id: number, data: Partial) { const db = await getDb(); if (!db) throw new Error("Database not available"); await db.update(users).set(data).where(eq(users.id, id)); } export async function listUsers() { const db = await getDb(); if (!db) return []; return db.select().from(users); } export async function deleteUser(id: number) { const db = await getDb(); if (!db) throw new Error("Database not available"); await db.delete(users).where(eq(users.id, id)); } export async function updateLastSignedIn(id: number) { const db = await getDb(); if (!db) return; await db .update(users) .set({ lastSignedIn: new Date() }) .where(eq(users.id, id)); } // ───────────────────────────────────────────────────────────────────────────── // ÉTABLISSEMENTS // ───────────────────────────────────────────────────────────────────────────── export async function listEtablissements() { const db = await getDb(); if (!db) return []; return db.select().from(etablissements).orderBy(asc(etablissements.code)); } export async function upsertEtablissement(etab: InsertEtablissement) { const db = await getDb(); if (!db) throw new Error("Database not available"); await db .insert(etablissements) .values(etab) .onDuplicateKeyUpdate({ set: { nom: etab.nom, groupe: etab.groupe, ville: etab.ville, actif: etab.actif, }, }); } export async function deleteEtablissement(code: string) { const db = await getDb(); if (!db) throw new Error("Database not available"); await db.delete(etablissements).where(eq(etablissements.code, code)); } // ───────────────────────────────────────────────────────────────────────────── // PARAMÈTRES // ───────────────────────────────────────────────────────────────────────────── export async function getParametres() { const db = await getDb(); if (!db) return []; return db.select().from(parametresApp); } export async function setParametre(cle: string, valeur: string) { const db = await getDb(); if (!db) throw new Error("Database not available"); await db .insert(parametresApp) .values({ cle, valeur }) .onDuplicateKeyUpdate({ set: { valeur } }); } // ───────────────────────────────────────────────────────────────────────────── // OPEX // ───────────────────────────────────────────────────────────────────────────── export async function getOpexPostes(annee: number) { const db = await getDb(); if (!db) return []; return db .select() .from(opexPostes) .where(eq(opexPostes.annee, annee)) .orderBy(asc(opexPostes.colIdx)); } export async function upsertOpexPoste(poste: InsertOpexPoste) { const db = await getDb(); if (!db) throw new Error("Database not available"); if (poste.id) { await db .update(opexPostes) .set(poste) // 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); } } export async function insertOpexPostes(postes: InsertOpexPoste[]) { const db = await getDb(); if (!db) throw new Error("Database not available"); if (postes.length === 0) return; await db.insert(opexPostes).values(postes); } export async function getOpexMontantsEtab(annee: number) { const db = await getDb(); if (!db) return []; return db .select() .from(opexMontantsEtab) .where(eq(opexMontantsEtab.annee, annee)); } export async function setOpexMontantEtab(data: InsertOpexMontantEtab) { const db = await getDb(); if (!db) throw new Error("Database not available"); await db .insert(opexMontantsEtab) .values(data) .onDuplicateKeyUpdate({ set: { montant: data.montant } }); } export async function insertOpexMontantsEtab(rows: InsertOpexMontantEtab[]) { const db = await getDb(); if (!db) throw new Error("Database not available"); if (rows.length === 0) return; 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) { const db = await getDb(); if (!db) return; 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 { 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; await db.delete(opexMontantsEtab).where(eq(opexMontantsEtab.annee, annee)); } export async function getOpexValidated(annee: number) { const db = await getDb(); if (!db) return null; const result = await db .select() .from(opexValidated) .where(eq(opexValidated.annee, annee)) .limit(1); return result.length > 0 ? result[0] : null; } export async function setOpexValidated(annee: number, userId: number) { const db = await getDb(); if (!db) throw new Error("Database not available"); await db .insert(opexValidated) .values({ annee, validatedBy: userId }) .onDuplicateKeyUpdate({ set: { validatedAt: new Date() } }); } // ───────────────────────────────────────────────────────────────────────────── // INVENTAIRE PC // ───────────────────────────────────────────────────────────────────────────── export async function getInventaire(annee: number) { const db = await getDb(); if (!db) return []; return db .select() .from(inventairePostes) .where(eq(inventairePostes.annee, annee)); } export async function getInventaireMeta(annee: number) { const db = await getDb(); if (!db) return null; const result = await db .select() .from(inventaireMeta) .where(eq(inventaireMeta.annee, annee)) .limit(1); return result.length > 0 ? result[0] : null; } export async function listInventaireMeta() { const db = await getDb(); if (!db) return []; return db.select().from(inventaireMeta).orderBy(inventaireMeta.annee); } export async function importInventaire( annee: number, postes: typeof inventairePostes.$inferInsert[], meta: { filename: string; nbEtablissements: number; nbFixes: number; nbPortables: number } ) { const db = await getDb(); if (!db) throw new Error("Database not available"); 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)); } } await tx .insert(inventaireMeta) .values({ annee, ...meta }) .onDuplicateKeyUpdate({ set: { ...meta, dateImport: new Date() } }); }); } // ───────────────────────────────────────────────────────────────────────────── // CAPEX // ───────────────────────────────────────────────────────────────────────────── export async function getCapexLignes(annee: number, etablissementCode: string) { const db = await getDb(); if (!db) return []; return db .select() .from(capexLignes) .where( and( eq(capexLignes.annee, annee), eq(capexLignes.etablissementCode, etablissementCode) ) ); } export async function saveCapexLignes( annee: number, etablissementCode: string, lignes: { cle: string; montant: string | null }[] ) { const db = await getDb(); if (!db) throw new Error("Database not available"); 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[]) { const db = await getDb(); if (!db) throw new Error("Database not available"); if (rows.length === 0) return; for (let i = 0; i < rows.length; i += 100) { 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"); const rows = await db .select() .from(opexBasesRepartition) .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: { 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"); 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), })) ); }); }