Files

753 lines
28 KiB
TypeScript

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<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, 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<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,
},
});
}
// ─────────────────────────────────────────────────────────────────────────────
// 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<SalaireLiasseCreateInput, "bulletins" | "statutExtraction" | "erreurExtraction">,
) {
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 };
}