Files
itinova-budget-si/drizzle/schema.ts

338 lines
20 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import {
boolean,
decimal,
int,
mysqlEnum,
mysqlTable,
text,
timestamp,
uniqueIndex,
varchar,
} from "drizzle-orm/mysql-core";
// ─────────────────────────────────────────────────────────────────────────────
// USERS — Auth locale (email/password), 3 profils : admin | standard | readonly
// ─────────────────────────────────────────────────────────────────────────────
export const users = mysqlTable("users", {
id: int("id").autoincrement().primaryKey(),
/** Identifiant de connexion (email ou login court comme adminItinova) */
login: varchar("login", { length: 255 }).notNull().unique(),
email: varchar("email", { length: 320 }),
passwordHash: varchar("passwordHash", { length: 255 }).notNull(),
firstName: varchar("firstName", { length: 100 }),
lastName: varchar("lastName", { length: 100 }),
role: mysqlEnum("role", ["admin", "standard", "readonly"]).default("standard").notNull(),
isActive: boolean("isActive").default(true).notNull(),
createdAt: timestamp("createdAt").defaultNow().notNull(),
updatedAt: timestamp("updatedAt").defaultNow().onUpdateNow().notNull(),
lastSignedIn: timestamp("lastSignedIn"),
});
export type User = typeof users.$inferSelect;
export type InsertUser = typeof users.$inferInsert;
// ─────────────────────────────────────────────────────────────────────────────
// ÉTABLISSEMENTS
// ─────────────────────────────────────────────────────────────────────────────
export const etablissements = mysqlTable("etablissements", {
id: int("id").autoincrement().primaryKey(),
code: varchar("code", { length: 50 }).notNull().unique(),
nom: varchar("nom", { length: 255 }).notNull(),
groupe: varchar("groupe", { length: 100 }),
ville: varchar("ville", { length: 100 }),
actif: boolean("actif").default(true).notNull(),
createdAt: timestamp("createdAt").defaultNow().notNull(),
updatedAt: timestamp("updatedAt").defaultNow().onUpdateNow().notNull(),
});
export type Etablissement = typeof etablissements.$inferSelect;
export type InsertEtablissement = typeof etablissements.$inferInsert;
// Rattachement utilisateur ↔ établissements (pour profil standard/readonly)
export const userEtablissements = mysqlTable("user_etablissements", {
id: int("id").autoincrement().primaryKey(),
userId: int("userId").notNull(),
etablissementCode: varchar("etablissementCode", { length: 50 }).notNull(),
});
// ─────────────────────────────────────────────────────────────────────────────
// PARAMÈTRES APPLICATION
// ─────────────────────────────────────────────────────────────────────────────
export const parametresApp = mysqlTable("parametres_app", {
id: int("id").autoincrement().primaryKey(),
cle: varchar("cle", { length: 100 }).notNull().unique(),
valeur: text("valeur"),
updatedAt: timestamp("updatedAt").defaultNow().onUpdateNow().notNull(),
});
// ─────────────────────────────────────────────────────────────────────────────
// OPEX — Postes budgétaires
// ─────────────────────────────────────────────────────────────────────────────
export const opexPostes = mysqlTable("opex_postes", {
id: int("id").autoincrement().primaryKey(),
annee: int("annee").notNull(),
colIdx: int("colIdx").notNull(),
libelle: varchar("libelle", { length: 255 }).notNull(),
libelleCourt: varchar("libelleCourt", { length: 100 }),
libelleDetail: varchar("libelleDetail", { length: 255 }),
fournisseur: varchar("fournisseur", { length: 255 }),
categorie: varchar("categorie", { length: 100 }),
type: varchar("type", { length: 100 }),
facturation: varchar("facturation", { length: 100 }),
modeVentilation: varchar("modeVentilation", { length: 50 }).default("Prorata C 6"),
compte: varchar("compte", { length: 50 }),
detail: text("detail"),
budgetN1: decimal("budgetN1", { precision: 12, scale: 2 }),
montant: decimal("montant", { precision: 12, scale: 2 }),
isCustom: boolean("isCustom").default(false),
createdAt: timestamp("createdAt").defaultNow().notNull(),
updatedAt: timestamp("updatedAt").defaultNow().onUpdateNow().notNull(),
}, (t) => ({
/** Empêche une initialisation répétée de créer le même poste pour une année. */
uniqAnneeLibelle: uniqueIndex("opex_postes_annee_libelle_idx").on(t.annee, t.libelle),
}));
export type OpexPoste = typeof opexPostes.$inferSelect;
export type InsertOpexPoste = typeof opexPostes.$inferInsert;
// Overrides manuels par établissement × poste × année
export const opexMontantsEtab = mysqlTable("opex_montants_etab", {
id: int("id").autoincrement().primaryKey(),
annee: int("annee").notNull(),
etablissementCode: varchar("etablissementCode", { length: 50 }).notNull(),
libellePoste: varchar("libellePoste", { length: 255 }).notNull(),
montant: decimal("montant", { precision: 12, scale: 2 }),
createdAt: timestamp("createdAt").defaultNow().notNull(),
updatedAt: timestamp("updatedAt").defaultNow().onUpdateNow().notNull(),
}, (t) => ({
/** Une seule surcharge manuelle par année, établissement et poste. */
uniqAnneeEtabPoste: uniqueIndex("opex_montants_annee_etab_poste_idx").on(
t.annee,
t.etablissementCode,
t.libellePoste,
),
}));
export type OpexMontantEtab = typeof opexMontantsEtab.$inferSelect;
export type InsertOpexMontantEtab = typeof opexMontantsEtab.$inferInsert;
// Validation définitive par année
export const opexValidated = mysqlTable("opex_validated", {
id: int("id").autoincrement().primaryKey(),
annee: int("annee").notNull().unique(),
validatedAt: timestamp("validatedAt").defaultNow().notNull(),
validatedBy: int("validatedBy"),
});
// ─────────────────────────────────────────────────────────────────────────────
// INVENTAIRE PC
// ─────────────────────────────────────────────────────────────────────────────
export const inventairePostes = mysqlTable("inventaire_postes", {
id: int("id").autoincrement().primaryKey(),
annee: int("annee").notNull(),
etablissementCode: varchar("etablissementCode", { length: 50 }).notNull(),
libelle: varchar("libelle", { length: 255 }),
typePoste: mysqlEnum("typePoste", ["fixe", "portable"]).notNull(),
dateRef: varchar("dateRef", { length: 20 }),
ageAns: decimal("ageAns", { precision: 5, scale: 2 }),
modele: varchar("modele", { length: 255 }),
fabricant: varchar("fabricant", { length: 100 }),
createdAt: timestamp("createdAt").defaultNow().notNull(),
});
export type InventairePoste = typeof inventairePostes.$inferSelect;
export type InsertInventairePoste = typeof inventairePostes.$inferInsert;
// Métadonnées d'import inventaire
export const inventaireMeta = mysqlTable("inventaire_meta", {
id: int("id").autoincrement().primaryKey(),
annee: int("annee").notNull().unique(),
filename: varchar("filename", { length: 255 }),
dateImport: timestamp("dateImport").defaultNow().notNull(),
nbEtablissements: int("nbEtablissements").default(0),
nbFixes: int("nbFixes").default(0),
nbPortables: int("nbPortables").default(0),
});
// ─────────────────────────────────────────────────────────────────────────────
// CAPEX — Lignes budgétaires par établissement
// ─────────────────────────────────────────────────────────────────────────────
export const capexLignes = mysqlTable("capex_lignes", {
id: int("id").autoincrement().primaryKey(),
annee: int("annee").notNull(),
etablissementCode: varchar("etablissementCode", { length: 50 }).notNull(),
cle: varchar("cle", { length: 100 }).notNull(),
// cles possibles: renouvellement_2027 | machines_supplementaires | appel_malade
// | telephonie | wifi | video_surveillance | copieurs | visio | autres | commentaires
montant: decimal("montant", { precision: 12, scale: 2 }),
createdAt: timestamp("createdAt").defaultNow().notNull(),
updatedAt: timestamp("updatedAt").defaultNow().onUpdateNow().notNull(),
}, (t) => ({
/** La sauvegarde CAPEX reste idempotente pour chaque clé métier. */
uniqAnneeEtabCle: uniqueIndex("capex_lignes_annee_etab_cle_idx").on(
t.annee,
t.etablissementCode,
t.cle,
),
}));
export type CapexLigne = typeof capexLignes.$inferSelect;
export type InsertCapexLigne = typeof capexLignes.$inferInsert;
// ───────────────────────────────────────────────────────────────────────────────
// OPEX — Bases de répartition par établissement et par année
// (charges de classe 6 utilisées pour le prorata)
// ───────────────────────────────────────────────────────────────────────────────
export const opexBasesRepartition = mysqlTable("opex_bases_repartition", {
id: int("id").autoincrement().primaryKey(),
annee: int("annee").notNull(),
etablissementCode: varchar("etablissementCode", { length: 50 }).notNull(),
etablissementNom: varchar("etablissementNom", { length: 255 }),
/** Charges classe 6 — base de répartition standard (tous établissements) */
baseRepartition: decimal("baseRepartition", { precision: 20, scale: 6 }).notNull(),
/** Charges classe 6 — base de répartition pôle HEP uniquement (0 pour les étblissements hors HEP) */
baseRepartitionHep: decimal("baseRepartitionHep", { precision: 20, scale: 6 }).default("0"),
/** Si true, tous les montants OPEX de cet établissement sont saisis manuellement (pas de calcul prorata) */
modeManuel: boolean("modeManuel").default(false).notNull(),
createdAt: timestamp("createdAt").defaultNow().notNull(),
}, (t) => ({
uniqAnneeEtab: uniqueIndex("opex_bases_repartition_annee_etab_idx").on(t.annee, t.etablissementCode),
}));
export type OpexBaseRepartition = typeof opexBasesRepartition.$inferSelect;
export type InsertOpexBaseRepartition = typeof opexBasesRepartition.$inferInsert;
// ─────────────────────────────────────────────────────────────────────────────
// MASSE SALARIALE SANTINOVA — Données sensibles, accès administrateur uniquement
// ─────────────────────────────────────────────────────────────────────────────
export const masseSalarialeSalaries = mysqlTable("masse_salariale_salaries", {
id: int("id").autoincrement().primaryKey(),
/** Identifiant paie stable, distinct des données personnelles superflues. */
matricule: varchar("matricule", { length: 32 }).notNull().unique(),
nom: varchar("nom", { length: 150 }).notNull(),
prenom: varchar("prenom", { length: 150 }).notNull(),
poste: varchar("poste", { length: 255 }).notNull(),
/** Date civile ISO (YYYY-MM-DD), sans fuseau horaire. */
dateEmbauche: varchar("dateEmbauche", { length: 10 }).notNull(),
actif: boolean("actif").default(true).notNull(),
createdAt: timestamp("createdAt").defaultNow().notNull(),
updatedAt: timestamp("updatedAt").defaultNow().onUpdateNow().notNull(),
});
export type MasseSalarialeSalarie = typeof masseSalarialeSalaries.$inferSelect;
export type InsertMasseSalarialeSalarie = typeof masseSalarialeSalaries.$inferInsert;
/**
* Une ligne par salarié et exercice. Tous les montants sont en centimes et les
* taux en points de base (ex. 42,75 % = 4 275) pour éviter les erreurs d'arrondi.
*/
export const masseSalarialeRemunerations = mysqlTable("masse_salariale_remunerations", {
id: int("id").autoincrement().primaryKey(),
salarieId: int("salarieId").notNull(),
annee: int("annee").notNull(),
/** Les lignes stockées constituent le prévisionnel ; le réel est dérivé des liasses mensuelles. */
nature: mysqlEnum("nature", ["previsionnel"]).notNull().default("previsionnel"),
salaireAnnuelBrutHorsPrimesCents: int("salaireAnnuelBrutHorsPrimesCents").notNull(),
salaireAnnuelBrutAvecPrimesCents: int("salaireAnnuelBrutAvecPrimesCents").notNull(),
salaireMensuelBrutHorsPrimesCents: int("salaireMensuelBrutHorsPrimesCents").notNull(),
tauxChargeBps: int("tauxChargeBps"),
/** Seules ces deux composantes de prévision sont éditables pour l'exercice courant ou futur. */
primeAstreinteCents: int("primeAstreinteCents").notNull().default(0),
primeCents: int("primeCents").notNull().default(0),
evolutionCents: int("evolutionCents").notNull().default(0),
/** Valeurs à l'import, utilisées pour recalculer le prévisionnel par différence lors d'une édition. */
primeReferenceCents: int("primeReferenceCents").notNull().default(0),
evolutionReferenceCents: int("evolutionReferenceCents").notNull().default(0),
/** Mois de paie ayant servi de référence, ex. 2025-12 ou 2026-08. */
periodeReference: varchar("periodeReference", { length: 7 }),
/** `annuel` pour un exercice clos ; `cumul_provisoire` pour une année en cours. */
statut: varchar("statut", { length: 32 }).notNull().default("annuel"),
source: varchar("source", { length: 64 }).notNull().default("bulletin"),
createdAt: timestamp("createdAt").defaultNow().notNull(),
updatedAt: timestamp("updatedAt").defaultNow().onUpdateNow().notNull(),
}, (t) => ({
/** Rend les imports idempotents pour un salarié, exercice et nature de donnée. */
uniqSalarieAnneeNature: uniqueIndex("masse_salariale_salarie_annee_nature_idx").on(t.salarieId, t.annee, t.nature),
}));
export type MasseSalarialeRemuneration = typeof masseSalarialeRemunerations.$inferSelect;
export type InsertMasseSalarialeRemuneration = typeof masseSalarialeRemunerations.$inferInsert;
/** Évolution salariale manuelle, stockée même en l'absence de bulletin pour l'année concernée. */
export const masseSalarialeEvolutions = mysqlTable("masse_salariale_evolutions", {
id: int("id").autoincrement().primaryKey(),
salarieId: int("salarieId").notNull(),
annee: int("annee").notNull(),
/** Pourcentage exprimé en points de base, ex. 1,50 % = 150. */
evolutionSalarialeBps: int("evolutionSalarialeBps").notNull(),
createdBy: int("createdBy").notNull(),
createdAt: timestamp("createdAt").defaultNow().notNull(),
updatedAt: timestamp("updatedAt").defaultNow().onUpdateNow().notNull(),
}, (t) => ({
uniqSalarieAnnee: uniqueIndex("masse_salariale_evolution_salarie_annee_idx").on(t.salarieId, t.annee),
}));
export type MasseSalarialeEvolution = typeof masseSalarialeEvolutions.$inferSelect;
export type InsertMasseSalarialeEvolution = typeof masseSalarialeEvolutions.$inferInsert;
// ─────────────────────────────────────────────────────────────────────────────
// SALAIRES MENSUELS — Liasses PDF chiffrées et données extraites
// ─────────────────────────────────────────────────────────────────────────────
/**
* Une liasse représente un dépôt mensuel. Le fichier est chiffré avant envoi
* vers le stockage persistant ; la base ne conserve ni octet de PDF ni clé.
*/
export const salairesLiasses = mysqlTable("salaires_liasses", {
id: int("id").autoincrement().primaryKey(),
annee: int("annee").notNull(),
mois: int("mois").notNull(),
nomFichier: varchar("nomFichier", { length: 255 }).notNull(),
stockageKey: varchar("stockageKey", { length: 512 }).notNull().unique(),
empreinteSha256: varchar("empreinteSha256", { length: 64 }).notNull().unique(),
tailleOctets: int("tailleOctets").notNull(),
/** Paramètres AES-256-GCM nécessaires au déchiffrement côté serveur. */
ivBase64: varchar("ivBase64", { length: 32 }).notNull(),
authTagBase64: varchar("authTagBase64", { length: 32 }).notNull(),
versionCle: int("versionCle").notNull().default(1),
/** `ready`, `partiel` ou `a_controler` : une liasse est conservée même si l'extraction est incomplète. */
statutExtraction: varchar("statutExtraction", { length: 32 }).notNull().default("ready"),
erreurExtraction: text("erreurExtraction"),
importePar: int("importePar").notNull(),
importeLe: timestamp("importeLe").defaultNow().notNull(),
}, (t) => ({
/** Une liasse unique par mois : toutes les fiches mensuelles restent ainsi cohérentes. */
uniqPeriode: uniqueIndex("salaires_liasses_periode_idx").on(t.annee, t.mois),
/** Empêche de traiter deux fois le même fichier sous une période distincte. */
uniqPeriodeEmpreinte: uniqueIndex("salaires_liasses_periode_empreinte_idx").on(t.annee, t.mois, t.empreinteSha256),
}));
export type SalaireLiasse = typeof salairesLiasses.$inferSelect;
export type InsertSalaireLiasse = typeof salairesLiasses.$inferInsert;
/**
* Données utiles par bulletin. `explicationEcart` conserve uniquement les
* rubriques de rémunération nécessaires à expliquer une variation mensuelle.
*/
export const salairesBulletins = mysqlTable("salaires_bulletins", {
id: int("id").autoincrement().primaryKey(),
liasseId: int("liasseId").notNull(),
matricule: varchar("matricule", { length: 32 }).notNull(),
nom: varchar("nom", { length: 150 }).notNull(),
prenom: varchar("prenom", { length: 150 }).notNull(),
poste: varchar("poste", { length: 255 }).notNull(),
brutMensuelCents: int("brutMensuelCents").notNull(),
brutAvecPrimesCents: int("brutAvecPrimesCents").notNull(),
primeAstreinteCents: int("primeAstreinteCents").notNull().default(0),
explicationEcart: text("explicationEcart"),
numeroPage: int("numeroPage"),
createdAt: timestamp("createdAt").defaultNow().notNull(),
updatedAt: timestamp("updatedAt").defaultNow().onUpdateNow().notNull(),
}, (t) => ({
/** Un seul bulletin indexé par matricule dans une liasse donnée. */
uniqLiasseMatricule: uniqueIndex("salaires_bulletins_liasse_matricule_idx").on(t.liasseId, t.matricule),
}));
export type SalaireBulletin = typeof salairesBulletins.$inferSelect;
export type InsertSalaireBulletin = typeof salairesBulletins.$inferInsert;