import { and, asc, desc, eq, inArray } from "drizzle-orm"; import { drizzle } from "drizzle-orm/mysql2"; import { capexLignes, etablissements, InsertCapexLigne, InsertEtablissement, InsertMasseSalarialeEvolution, InsertMasseSalarialeRemuneration, InsertMasseSalarialeSalarie, InsertOpexMontantEtab, InsertOpexPoste, InsertUser, inventaireMeta, inventairePostes, masseSalarialeEvolutions, masseSalarialeRemunerations, masseSalarialeSalaries, opexBasesRepartition, opexMontantsEtab, opexPostes, opexValidated, parametresApp, salairesBulletins, salairesLiasses, 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), })) ); }); } // ───────────────────────────────────────────────────────────────────────────── // MASSE SALARIALE — accès exclusivement appelés par des procédures admin // ───────────────────────────────────────────────────────────────────────────── export type MasseSalarialeImportSalarie = Pick< InsertMasseSalarialeSalarie, "matricule" | "nom" | "prenom" | "poste" | "dateEmbauche" >; export type MasseSalarialeImportRemuneration = Omit< InsertMasseSalarialeRemuneration, "id" | "salarieId" | "createdAt" | "updatedAt" > & { matricule: string }; /** * Retourne les trois sources normalisées séparément. Le routeur réalise la * jointure logique afin d'éviter une matrice SQL qui dupliquerait les années. */ export async function getMasseSalariale() { const db = await getDb(); if (!db) throw new Error("Database not available"); const [salaries, remunerations, evolutions, bulletinsMensuels] = await Promise.all([ db.select().from(masseSalarialeSalaries).orderBy(asc(masseSalarialeSalaries.nom), asc(masseSalarialeSalaries.prenom)), db.select().from(masseSalarialeRemunerations), db.select().from(masseSalarialeEvolutions), db.select({ annee: salairesLiasses.annee, mois: salairesLiasses.mois, matricule: salairesBulletins.matricule, brutMensuelCents: salairesBulletins.brutMensuelCents, brutAvecPrimesCents: salairesBulletins.brutAvecPrimesCents, primeAstreinteCents: salairesBulletins.primeAstreinteCents, }).from(salairesBulletins).innerJoin(salairesLiasses, eq(salairesBulletins.liasseId, salairesLiasses.id)), ]); return { salaries, remunerations, evolutions, bulletinsMensuels }; } /** * Import idempotent : les salaires et leurs rémunérations annuelles sont * remplacés dans une transaction, sans modifier les évolutions saisies à la * main qui résident dans une table distincte. */ export async function importMasseSalariale( salaries: MasseSalarialeImportSalarie[], remunerations: MasseSalarialeImportRemuneration[], options: { replaceForecastYears?: number[] } = {}, ) { const db = await getDb(); if (!db) throw new Error("Database not available"); if (salaries.length === 0) return { salaries: 0, remunerations: 0 }; return db.transaction(async (tx) => { for (const salarie of salaries) { await tx .insert(masseSalarialeSalaries) .values({ ...salarie, actif: true }) .onDuplicateKeyUpdate({ set: { nom: salarie.nom, prenom: salarie.prenom, poste: salarie.poste, dateEmbauche: salarie.dateEmbauche, actif: true, }, }); } const persistedSalaries = await tx .select({ id: masseSalarialeSalaries.id, matricule: masseSalarialeSalaries.matricule }) .from(masseSalarialeSalaries); const idsByMatricule = new Map(persistedSalaries.map((row) => [row.matricule, row.id])); if (options.replaceForecastYears?.length) { await tx.delete(masseSalarialeRemunerations).where(and( inArray(masseSalarialeRemunerations.annee, options.replaceForecastYears), eq(masseSalarialeRemunerations.nature, "previsionnel"), )); } for (const remuneration of remunerations) { const salarieId = idsByMatricule.get(remuneration.matricule); if (!salarieId) { throw new Error(`Salarié introuvable lors de l'import : ${remuneration.matricule}`); } const { matricule: _matricule, ...values } = remuneration; await tx .insert(masseSalarialeRemunerations) .values({ ...values, salarieId }) .onDuplicateKeyUpdate({ set: { salaireAnnuelBrutHorsPrimesCents: values.salaireAnnuelBrutHorsPrimesCents, salaireAnnuelBrutAvecPrimesCents: values.salaireAnnuelBrutAvecPrimesCents, salaireMensuelBrutHorsPrimesCents: values.salaireMensuelBrutHorsPrimesCents, tauxChargeBps: values.tauxChargeBps, nature: values.nature, primeAstreinteCents: values.primeAstreinteCents, primeCents: values.primeCents, evolutionCents: values.evolutionCents, primeReferenceCents: values.primeReferenceCents, evolutionReferenceCents: values.evolutionReferenceCents, periodeReference: values.periodeReference, statut: values.statut, source: values.source, }, }); } return { salaries: salaries.length, remunerations: remunerations.length }; }); } /** Met à jour uniquement les deux hypothèses qui peuvent être modifiées. */ export async function setMasseSalarialeForecastAdjustments(input: { salarieId: number; annee: number; primeCents: number; evolutionCents: number; }) { const db = await getDb(); if (!db) throw new Error("Database not available"); await db.update(masseSalarialeRemunerations).set({ primeCents: input.primeCents, evolutionCents: input.evolutionCents, }).where(and( eq(masseSalarialeRemunerations.salarieId, input.salarieId), eq(masseSalarialeRemunerations.annee, input.annee), eq(masseSalarialeRemunerations.nature, "previsionnel"), )); } /** Sauvegarde l'évolution manuelle sans dépendre de l'existence d'un bulletin. */ export async function setMasseSalarialeEvolution( input: Pick, ) { const db = await getDb(); if (!db) throw new Error("Database not available"); await db .insert(masseSalarialeEvolutions) .values(input) .onDuplicateKeyUpdate({ set: { evolutionSalarialeBps: input.evolutionSalarialeBps, createdBy: input.createdBy, }, }); } // ───────────────────────────────────────────────────────────────────────────── // SALAIRES MENSUELS — Métadonnées de liasses et index de bulletins chiffrés // ───────────────────────────────────────────────────────────────────────────── export type SalaireBulletinImport = { matricule: string; nom: string; prenom: string; poste: string; brutMensuelCents: number; brutAvecPrimesCents: number; primeAstreinteCents: number; explicationEcart?: string | null; numeroPage?: number | null; }; export type SalaireLiasseCreateInput = { annee: number; mois: number; nomFichier: string; stockageKey: string; empreinteSha256: string; tailleOctets: number; ivBase64: string; authTagBase64: string; importePar: number; statutExtraction: "ready" | "partiel" | "a_controler"; erreurExtraction?: string | null; bulletins: SalaireBulletinImport[]; }; /** Retourne une liasse de période pour empêcher une importation mensuelle multiple. */ export async function getSalaireLiasseByPeriod(annee: number, mois: number) { const db = await getDb(); if (!db) throw new Error("Database not available"); const [liasse] = await db .select() .from(salairesLiasses) .where(and(eq(salairesLiasses.annee, annee), eq(salairesLiasses.mois, mois))); return liasse ?? null; } /** Retourne une liasse par identifiant, sans jamais renvoyer de fichier déchiffré. */ export async function getSalaireLiasseById(id: number) { const db = await getDb(); if (!db) throw new Error("Database not available"); const [liasse] = await db.select().from(salairesLiasses).where(eq(salairesLiasses.id, id)); return liasse ?? null; } /** * Persiste de façon atomique la référence chiffrée de la liasse et son index * métier. Les octets du PDF restent exclusivement dans le stockage persistant. */ export async function createSalaireLiasse(input: SalaireLiasseCreateInput) { const db = await getDb(); if (!db) throw new Error("Database not available"); return db.transaction(async (tx) => { const [existing] = await tx .select({ id: salairesLiasses.id }) .from(salairesLiasses) .where(and(eq(salairesLiasses.annee, input.annee), eq(salairesLiasses.mois, input.mois))); if (existing) throw new Error("Une liasse est déjà enregistrée pour cette période"); await tx.insert(salairesLiasses).values({ annee: input.annee, mois: input.mois, nomFichier: input.nomFichier, stockageKey: input.stockageKey, empreinteSha256: input.empreinteSha256, tailleOctets: input.tailleOctets, ivBase64: input.ivBase64, authTagBase64: input.authTagBase64, importePar: input.importePar, statutExtraction: input.statutExtraction, erreurExtraction: input.erreurExtraction ?? null, }); const [liasse] = await tx .select({ id: salairesLiasses.id }) .from(salairesLiasses) .where(eq(salairesLiasses.stockageKey, input.stockageKey)); if (!liasse) throw new Error("Liasse non retrouvée après enregistrement"); if (input.bulletins.length > 0) { await tx.insert(salairesBulletins).values(input.bulletins.map((bulletin) => ({ ...bulletin, liasseId: liasse.id, }))); } return { liasseId: liasse.id, bulletins: input.bulletins.length }; }); } /** * Met à jour l'index métier d'une liasse déjà archivée sans modifier sa clé de * stockage, son empreinte ou ses paramètres de chiffrement. */ export async function replaceSalaireLiasseIndex( liasseId: number, input: Pick, ) { const db = await getDb(); if (!db) throw new Error("Database not available"); return db.transaction(async (tx) => { await tx.delete(salairesBulletins).where(eq(salairesBulletins.liasseId, liasseId)); await tx.update(salairesLiasses).set({ statutExtraction: input.statutExtraction, erreurExtraction: input.erreurExtraction ?? null, }).where(eq(salairesLiasses.id, liasseId)); if (input.bulletins.length > 0) { await tx.insert(salairesBulletins).values(input.bulletins.map((bulletin) => ({ ...bulletin, liasseId }))); } return { bulletins: input.bulletins.length }; }); } /** * Liste les liasses filtrées et les bulletins associés. La comparaison est * réalisée dans le routeur à partir de ces montants normalisés en centimes. */ export async function getSalairesMensuels(annee?: number, mois?: number) { const db = await getDb(); if (!db) throw new Error("Database not available"); const filter = annee && mois ? and(eq(salairesLiasses.annee, annee), eq(salairesLiasses.mois, mois)) : annee ? eq(salairesLiasses.annee, annee) : mois ? eq(salairesLiasses.mois, mois) : undefined; const liassesQuery = db.select().from(salairesLiasses); const liasses = filter ? await liassesQuery.where(filter).orderBy(desc(salairesLiasses.annee), desc(salairesLiasses.mois)) : await liassesQuery.orderBy(desc(salairesLiasses.annee), desc(salairesLiasses.mois)); const bulletinsQuery = db .select({ liasse: salairesLiasses, bulletin: salairesBulletins }) .from(salairesBulletins) .innerJoin(salairesLiasses, eq(salairesBulletins.liasseId, salairesLiasses.id)); const rows = filter ? await bulletinsQuery.where(filter).orderBy(asc(salairesBulletins.nom), asc(salairesBulletins.prenom)) : await bulletinsQuery.orderBy(desc(salairesLiasses.annee), desc(salairesLiasses.mois), asc(salairesBulletins.nom)); return { liasses, rows }; }