Checkpoint: Ajout du module Masse salariale SANTINOVA : tables de salariés, rémunérations et évolutions manuelles sécurisées par rôle administrateur ; import contrôlé des bulletins 2025 et janvier–août 2026 ; écran tableau 2025–2027 ; tests d'autorisations, d'agrégats et des quatre états UI ; documentation des sources et de la politique d'import.

This commit is contained in:
Manus
2026-08-27 13:10:47 +00:00
parent 2f4b1f2e8e
commit da407db4de
21 changed files with 4390 additions and 4 deletions

View File

@@ -71,6 +71,9 @@ vi.mock("./db", async (importOriginal) => {
upsertOpexBaseRepartition: vi.fn(),
setOpexMontantsEtabBatch: vi.fn(),
deleteOpexPoste: vi.fn(),
getMasseSalariale: vi.fn(),
importMasseSalariale: vi.fn(),
setMasseSalarialeEvolution: vi.fn(),
};
});
@@ -342,3 +345,92 @@ describe("opex.deletePoste", () => {
expect(db.deleteOpexPoste).toHaveBeenCalledWith(2027, 42);
});
});
// ─── Tests Masse salariale (données sensibles, administrateur uniquement) ─────
describe("masseSalariale", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("retourne les données uniquement pour un administrateur", async () => {
const { db } = await import("./db").then(m => ({ db: m }));
const source = { salaries: [], remunerations: [], evolutions: [] };
(db.getMasseSalariale as ReturnType<typeof vi.fn>).mockResolvedValue(source);
const { ctx } = createAuthCtx({ role: "admin" });
await expect(appRouter.createCaller(ctx).masseSalariale.get()).resolves.toEqual(source);
});
it("interdit la consultation à un profil standard", async () => {
const { ctx } = createAuthCtx({ role: "standard" });
await expect(appRouter.createCaller(ctx).masseSalariale.get()).rejects.toThrow();
});
it("importe les données validées uniquement pour un administrateur", async () => {
const { db } = await import("./db").then(m => ({ db: m }));
(db.importMasseSalariale as ReturnType<typeof vi.fn>).mockResolvedValue({ salaries: 1, remunerations: 1 });
const { ctx } = createAuthCtx({ role: "admin" });
const salaries = [{
matricule: "000001",
nom: "DUPONT",
prenom: "Alice",
poste: "Responsable administratif",
dateEmbauche: "2024-01-01",
}];
const remunerations = [{
matricule: "000001",
annee: 2026,
salaireAnnuelBrutHorsPrimesCents: 36_000_00,
salaireAnnuelBrutAvecPrimesCents: 40_000_00,
salaireMensuelBrutHorsPrimesCents: 3_000_00,
tauxChargeBps: 5_500,
periodeReference: "2026-08",
statut: "cumul_provisoire" as const,
source: "bulletin",
}];
await expect(appRouter.createCaller(ctx).masseSalariale.import({ salaries, remunerations }))
.resolves.toEqual({ salaries: 1, remunerations: 1 });
expect(db.importMasseSalariale).toHaveBeenCalledWith(salaries, remunerations);
});
it("interdit l'import de rémunérations à un profil standard", async () => {
const { db } = await import("./db").then(m => ({ db: m }));
const { ctx } = createAuthCtx({ role: "standard" });
await expect(appRouter.createCaller(ctx).masseSalariale.import({ salaries: [], remunerations: [] })).rejects.toThrow();
expect(db.importMasseSalariale).not.toHaveBeenCalled();
});
it("enregistre l'évolution manuelle avec l'identifiant de l'administrateur", async () => {
const { db } = await import("./db").then(m => ({ db: m }));
const { ctx } = createAuthCtx({ id: 42, role: "admin" });
await expect(appRouter.createCaller(ctx).masseSalariale.setEvolution({
salarieId: 7,
annee: 2027,
evolutionSalarialeBps: 250,
})).resolves.toEqual({ success: true });
expect(db.setMasseSalarialeEvolution).toHaveBeenCalledWith({
salarieId: 7,
annee: 2027,
evolutionSalarialeBps: 250,
createdBy: 42,
});
});
it("rejette un taux d'évolution hors plage avant l'accès à la base", async () => {
const { db } = await import("./db").then(m => ({ db: m }));
const { ctx } = createAuthCtx({ role: "admin" });
await expect(appRouter.createCaller(ctx).masseSalariale.setEvolution({
salarieId: 7,
annee: 2027,
evolutionSalarialeBps: 10_001,
})).rejects.toThrow();
expect(db.setMasseSalarialeEvolution).not.toHaveBeenCalled();
});
});

View File

@@ -5,11 +5,17 @@ import {
etablissements,
InsertCapexLigne,
InsertEtablissement,
InsertMasseSalarialeEvolution,
InsertMasseSalarialeRemuneration,
InsertMasseSalarialeSalarie,
InsertOpexMontantEtab,
InsertOpexPoste,
InsertUser,
inventaireMeta,
inventairePostes,
masseSalarialeEvolutions,
masseSalarialeRemunerations,
masseSalarialeSalaries,
opexBasesRepartition,
opexMontantsEtab,
opexPostes,
@@ -448,3 +454,108 @@ export async function importOpexBasesRepartition(
);
});
}
// ─────────────────────────────────────────────────────────────────────────────
// 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,
},
});
}

View File

@@ -248,6 +248,47 @@ export const appRouter = router({
}),
}),
// Les données de rémunération sont sensibles : aucune procédure de ce
// sous-routeur n'est accessible aux profils standard ou lecture seule.
masseSalariale: router({
get: adminProcedure.query(async () => db.getMasseSalariale()),
import: adminProcedure
.input(z.object({
salaries: z.array(z.object({
matricule: z.string().trim().min(1).max(32),
nom: z.string().trim().min(1).max(150),
prenom: z.string().trim().min(1).max(150),
poste: z.string().trim().min(1).max(255),
dateEmbauche: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, "Date d'embauche invalide"),
})).min(1).max(500),
remunerations: z.array(z.object({
matricule: z.string().trim().min(1).max(32),
annee: z.number().int().min(2000).max(2100),
salaireAnnuelBrutHorsPrimesCents: z.number().int().min(0).max(100_000_000),
salaireAnnuelBrutAvecPrimesCents: z.number().int().min(0).max(100_000_000),
salaireMensuelBrutHorsPrimesCents: z.number().int().min(0).max(10_000_000),
tauxChargeBps: z.number().int().min(0).max(100_000),
periodeReference: z.string().regex(/^\d{4}-\d{2}$/).nullable().optional(),
statut: z.enum(["annuel", "cumul_provisoire"]),
source: z.string().trim().min(1).max(64),
})).max(1_500),
}))
.mutation(async ({ input }) => db.importMasseSalariale(input.salaries, input.remunerations)),
setEvolution: adminProcedure
.input(z.object({
salarieId: z.number().int().positive(),
annee: z.number().int().min(2000).max(2100),
// ±100 % maximum : protège contre les erreurs de virgule dans la saisie.
evolutionSalarialeBps: z.number().int().min(-10_000).max(10_000),
}))
.mutation(async ({ input, ctx }) => {
await db.setMasseSalarialeEvolution({ ...input, createdBy: ctx.user.id });
return { success: true };
}),
}),
inventaire: router({
get: protectedProcedure
.input(z.object({ annee: z.number() }))