562 lines
20 KiB
TypeScript
562 lines
20 KiB
TypeScript
import { and, asc, eq } 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,
|
|
users,
|
|
} from "../drizzle/schema";
|
|
import { isOpexEtablissementCode } from "../shared/opexValidation";
|
|
|
|
let _db: ReturnType<typeof drizzle> | 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<InsertUser>) {
|
|
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<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;
|
|
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] = await Promise.all([
|
|
db.select().from(masseSalarialeSalaries).orderBy(asc(masseSalarialeSalaries.nom), asc(masseSalarialeSalaries.prenom)),
|
|
db.select().from(masseSalarialeRemunerations),
|
|
db.select().from(masseSalarialeEvolutions),
|
|
]);
|
|
return { salaries, remunerations, evolutions };
|
|
}
|
|
|
|
/**
|
|
* 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[],
|
|
) {
|
|
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]));
|
|
|
|
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,
|
|
periodeReference: values.periodeReference,
|
|
statut: values.statut,
|
|
source: values.source,
|
|
},
|
|
});
|
|
}
|
|
return { salaries: salaries.length, remunerations: remunerations.length };
|
|
});
|
|
}
|
|
|
|
/** Sauvegarde l'évolution manuelle sans dépendre de l'existence d'un bulletin. */
|
|
export async function setMasseSalarialeEvolution(
|
|
input: Pick<InsertMasseSalarialeEvolution, "salarieId" | "annee" | "evolutionSalarialeBps" | "createdBy">,
|
|
) {
|
|
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,
|
|
},
|
|
});
|
|
}
|