Checkpoint: Fenêtre administrateur Salaires finalisée : import multipart limité, chiffrement AES-256-GCM avant stockage persistant hors conteneur, métadonnées/index métier minimisés en base, consultation PDF déchiffrée côté serveur avec no-store, filtres année/mois et comparaison M-1 expliquée. Réindexation sûre des archives partielles sans écrasement du PDF. Validation réelle sur deux liasses mensuelles : index partiel signalé sans invention de données, comparaison affichée et lecture après redémarrage. 55 tests Vitest, TypeScript et build de production validés.
Some checks failed
Validation applicative / TypeScript, tests et build (push) Failing after 1m54s
Some checks failed
Validation applicative / TypeScript, tests et build (push) Failing after 1m54s
This commit is contained in:
@@ -8,6 +8,7 @@ import { sdk } from "./_core/sdk";
|
||||
import { systemRouter } from "./_core/systemRouter";
|
||||
import { adminProcedure, protectedProcedure, publicProcedure, router } from "./_core/trpc";
|
||||
import { isOpexEtablissementCode, normalizeOpexEtablissementCode } from "../shared/opexValidation";
|
||||
import { getPayrollEncryptionStatus } from "./payrollCrypto";
|
||||
|
||||
const writeProcedure = protectedProcedure.use(({ ctx, next }) => {
|
||||
if (ctx.user.role === "readonly") {
|
||||
@@ -22,6 +23,35 @@ const opexEtablissementCodeSchema = z
|
||||
.transform(normalizeOpexEtablissementCode)
|
||||
.refine(isOpexEtablissementCode, { message: "Code établissement OPEX invalide" });
|
||||
|
||||
const salaireFiltersSchema = z.object({
|
||||
annee: z.number().int().min(2020).max(2100).optional(),
|
||||
mois: z.number().int().min(1).max(12).optional(),
|
||||
});
|
||||
|
||||
function periodKey(annee: number, mois: number) {
|
||||
return annee * 12 + (mois - 1);
|
||||
}
|
||||
|
||||
function describeMonthlySalaryDifference(
|
||||
current: { brutMensuelCents: number; brutAvecPrimesCents: number; primeAstreinteCents: number },
|
||||
previous: { brutMensuelCents: number; brutAvecPrimesCents: number; primeAstreinteCents: number } | undefined,
|
||||
) {
|
||||
if (!previous) return "Aucun bulletin de ce salarié disponible au mois précédent.";
|
||||
const difference = current.brutAvecPrimesCents - previous.brutAvecPrimesCents;
|
||||
if (difference === 0) return "Brut stable par rapport au mois précédent.";
|
||||
|
||||
const sign = (amount: number) => amount > 0 ? "hausse" : "baisse";
|
||||
const centsLabel = (amount: number) => `${(Math.abs(amount) / 100).toLocaleString("fr-FR", { minimumFractionDigits: 2, maximumFractionDigits: 2 })} €`;
|
||||
const reasons: string[] = [];
|
||||
const baseDifference = current.brutMensuelCents - previous.brutMensuelCents;
|
||||
const astreinteDifference = current.primeAstreinteCents - previous.primeAstreinteCents;
|
||||
const otherDifference = difference - baseDifference - astreinteDifference;
|
||||
if (baseDifference !== 0) reasons.push(`${sign(baseDifference)} de la base (${centsLabel(baseDifference)})`);
|
||||
if (astreinteDifference !== 0) reasons.push(`${sign(astreinteDifference)} des astreintes (${centsLabel(astreinteDifference)})`);
|
||||
if (otherDifference !== 0) reasons.push(`${sign(otherDifference)} d'autres éléments inclus au brut (${centsLabel(otherDifference)})`);
|
||||
return reasons.length > 0 ? reasons.join(" ; ") : "Variation du brut à contrôler.";
|
||||
}
|
||||
|
||||
export const appRouter = router({
|
||||
system: systemRouter,
|
||||
|
||||
@@ -248,6 +278,88 @@ export const appRouter = router({
|
||||
}),
|
||||
}),
|
||||
|
||||
/** Informations de disponibilité du stockage salarial, sans divulgation de secret. */
|
||||
salaires: router({
|
||||
getSecurityStatus: adminProcedure.query(() => getPayrollEncryptionStatus()),
|
||||
|
||||
periods: adminProcedure.query(async () => {
|
||||
const { liasses } = await db.getSalairesMensuels();
|
||||
return liasses.map((liasse) => ({
|
||||
id: liasse.id,
|
||||
annee: liasse.annee,
|
||||
mois: liasse.mois,
|
||||
nomFichier: liasse.nomFichier,
|
||||
tailleOctets: liasse.tailleOctets,
|
||||
statutExtraction: liasse.statutExtraction,
|
||||
erreurExtraction: liasse.erreurExtraction,
|
||||
importeLe: liasse.importeLe,
|
||||
}));
|
||||
}),
|
||||
|
||||
/**
|
||||
* Renvoie exclusivement l'index métier des bulletins, jamais les références
|
||||
* de stockage, empreintes ou paramètres cryptographiques.
|
||||
*/
|
||||
list: adminProcedure.input(salaireFiltersSchema).query(async ({ input }) => {
|
||||
const { liasses, rows } = await db.getSalairesMensuels();
|
||||
const liassesByPeriod = new Map(liasses.map((liasse) => [periodKey(liasse.annee, liasse.mois), liasse]));
|
||||
const rowsByLiasse = new Map<number, Map<string, typeof rows[number]["bulletin"]>>();
|
||||
rows.forEach(({ liasse, bulletin }) => {
|
||||
const byEmployee = rowsByLiasse.get(liasse.id) ?? new Map();
|
||||
byEmployee.set(bulletin.matricule, bulletin);
|
||||
rowsByLiasse.set(liasse.id, byEmployee);
|
||||
});
|
||||
|
||||
const selectedLiasses = liasses.filter((liasse) =>
|
||||
(!input.annee || liasse.annee === input.annee) && (!input.mois || liasse.mois === input.mois),
|
||||
);
|
||||
const selectedLiasseIds = new Set(selectedLiasses.map((liasse) => liasse.id));
|
||||
const bulletins = rows
|
||||
.filter(({ liasse }) => selectedLiasseIds.has(liasse.id))
|
||||
.map(({ liasse, bulletin }) => {
|
||||
const previousLiasse = liassesByPeriod.get(periodKey(liasse.annee, liasse.mois) - 1);
|
||||
const previous = previousLiasse ? rowsByLiasse.get(previousLiasse.id)?.get(bulletin.matricule) : undefined;
|
||||
const ecartBrutCents = previous ? bulletin.brutAvecPrimesCents - previous.brutAvecPrimesCents : null;
|
||||
return {
|
||||
liasseId: liasse.id,
|
||||
annee: liasse.annee,
|
||||
mois: liasse.mois,
|
||||
matricule: bulletin.matricule,
|
||||
nom: bulletin.nom,
|
||||
prenom: bulletin.prenom,
|
||||
poste: bulletin.poste,
|
||||
brutMensuelCents: bulletin.brutMensuelCents,
|
||||
brutAvecPrimesCents: bulletin.brutAvecPrimesCents,
|
||||
primeAstreinteCents: bulletin.primeAstreinteCents,
|
||||
brutPrecedentCents: previous?.brutAvecPrimesCents ?? null,
|
||||
ecartBrutCents,
|
||||
ecartBrutPourcentage: previous && previous.brutAvecPrimesCents !== 0
|
||||
? Math.round((ecartBrutCents! / previous.brutAvecPrimesCents) * 10_000) / 100
|
||||
: null,
|
||||
explication: describeMonthlySalaryDifference(bulletin, previous),
|
||||
numeroPage: bulletin.numeroPage,
|
||||
};
|
||||
});
|
||||
const totalBrutCents = bulletins.reduce((total, bulletin) => total + bulletin.brutAvecPrimesCents, 0);
|
||||
const totalEcartCents = bulletins.reduce((total, bulletin) => total + (bulletin.ecartBrutCents ?? 0), 0);
|
||||
return {
|
||||
liasses: selectedLiasses.map((liasse) => ({
|
||||
id: liasse.id,
|
||||
annee: liasse.annee,
|
||||
mois: liasse.mois,
|
||||
nomFichier: liasse.nomFichier,
|
||||
tailleOctets: liasse.tailleOctets,
|
||||
statutExtraction: liasse.statutExtraction,
|
||||
erreurExtraction: liasse.erreurExtraction,
|
||||
importeLe: liasse.importeLe,
|
||||
pdfPath: `/api/salaires/liasses/${liasse.id}/pdf`,
|
||||
})),
|
||||
bulletins,
|
||||
indicateurs: { totalBrutCents, totalEcartCents, nombreSalaries: bulletins.length },
|
||||
};
|
||||
}),
|
||||
}),
|
||||
|
||||
// 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({
|
||||
|
||||
Reference in New Issue
Block a user