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:
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
111
server/db.ts
111
server/db.ts
@@ -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,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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() }))
|
||||
|
||||
Reference in New Issue
Block a user