Checkpoint: Ajout de la projection annuelle prévisionnel-réel dans Masse salariale : import contrôlé du fichier modèle 2025-2027, réel 2026 provenant uniquement des liasses Salaires, ajustements administrateur limités aux primes et évolutions de l'année courante/future, conservation des centimes, modèle de calcul testé et documentation. 72 tests, TypeScript et build validés.

This commit is contained in:
Manus
2026-08-31 10:58:02 +00:00
parent 5c9618b204
commit 28570234d0
17 changed files with 2415 additions and 204 deletions

View File

@@ -9,6 +9,12 @@ import { systemRouter } from "./_core/systemRouter";
import { adminProcedure, protectedProcedure, publicProcedure, router } from "./_core/trpc";
import { isOpexEtablissementCode, normalizeOpexEtablissementCode } from "../shared/opexValidation";
import { getPayrollEncryptionStatus } from "./payrollCrypto";
import {
buildAnnualForecasts,
buildAnnualPayrollActuals,
isForecastAdjustmentEditable,
} from "./masseSalarialeProjection";
import type { AnnualProjection } from "./masseSalarialeProjection";
const writeProcedure = protectedProcedure.use(({ ctx, next }) => {
if (ctx.user.role === "readonly") {
@@ -363,7 +369,74 @@ 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()),
get: adminProcedure.query(async () => {
const source = await db.getMasseSalariale();
const currentYear = new Date().getFullYear();
const forecastsBySalarie = new Map<number, Map<number, AnnualProjection>>();
for (const remuneration of source.remunerations) {
const projection = buildAnnualForecasts([{
annee: remuneration.annee,
brutHorsAstreinteReferenceCents: remuneration.salaireAnnuelBrutHorsPrimesCents,
brutAvecPrimesReferenceCents: remuneration.salaireAnnuelBrutAvecPrimesCents,
primeAstreinteCents: remuneration.primeAstreinteCents,
primeCents: remuneration.primeCents,
primeReferenceCents: remuneration.primeReferenceCents,
evolutionCents: remuneration.evolutionCents,
evolutionReferenceCents: remuneration.evolutionReferenceCents,
tauxChargeBps: remuneration.tauxChargeBps,
}]).get(remuneration.annee);
if (!projection) continue;
const byYear = forecastsBySalarie.get(remuneration.salarieId) ?? new Map();
byYear.set(remuneration.annee, projection);
forecastsBySalarie.set(remuneration.salarieId, byYear);
}
const payrollActuals = buildAnnualPayrollActuals(source.bulletinsMensuels ?? []);
const forecasts = source.remunerations.flatMap((remuneration) => {
const projection = forecastsBySalarie.get(remuneration.salarieId)?.get(remuneration.annee);
if (!projection) return [];
return [{
salarieId: remuneration.salarieId,
annee: remuneration.annee,
...projection,
primeCents: remuneration.primeCents,
evolutionCents: remuneration.evolutionCents,
editable: isForecastAdjustmentEditable(remuneration.annee, currentYear),
}];
});
const actuals: Array<AnnualProjection & {
salarieId: number;
annee: number;
moisDisponibles: number[];
origine: "liasses" | "cloture_2025";
}> = [];
for (const salarie of source.salaries) {
const forecast2025 = forecastsBySalarie.get(salarie.id)?.get(2025);
const archivedActuals = payrollActuals.get(salarie.matricule);
for (const [annee, actual] of Array.from(archivedActuals?.entries() ?? [])) {
actuals.push({
salarieId: salarie.id,
annee,
...actual,
origine: "liasses" as const,
evolutionCents: 0,
tauxChargeBps: null,
});
}
if (forecast2025) {
actuals.push({
salarieId: salarie.id,
annee: 2025,
...forecast2025,
moisDisponibles: Array.from({ length: 12 }, (_, index) => index + 1),
origine: "cloture_2025" as const,
});
}
}
return { salaries: source.salaries, forecasts, actuals, currentYear };
}),
import: adminProcedure
.input(z.object({
@@ -380,7 +453,13 @@ export const appRouter = router({
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),
tauxChargeBps: z.number().int().min(0).max(100_000).nullable(),
nature: z.literal("previsionnel").default("previsionnel"),
primeAstreinteCents: z.number().int().min(-100_000_000).max(100_000_000).default(0),
primeCents: z.number().int().min(-100_000_000).max(100_000_000).default(0),
evolutionCents: z.number().int().min(-100_000_000).max(100_000_000).default(0),
primeReferenceCents: z.number().int().min(-100_000_000).max(100_000_000).default(0),
evolutionReferenceCents: z.number().int().min(-100_000_000).max(100_000_000).default(0),
periodeReference: z.string().regex(/^\d{4}-\d{2}$/).nullable().optional(),
statut: z.enum(["annuel", "cumul_provisoire"]),
source: z.string().trim().min(1).max(64),
@@ -396,9 +475,27 @@ export const appRouter = router({
evolutionSalarialeBps: z.number().int().min(-10_000).max(10_000),
}))
.mutation(async ({ input, ctx }) => {
if (!isForecastAdjustmentEditable(input.annee, new Date().getFullYear())) {
throw new TRPCError({ code: "FORBIDDEN", message: "Les exercices antérieurs ne sont pas modifiables" });
}
await db.setMasseSalarialeEvolution({ ...input, createdBy: ctx.user.id });
return { success: true };
}),
setForecastAdjustments: adminProcedure
.input(z.object({
salarieId: z.number().int().positive(),
annee: z.number().int().min(2000).max(2100),
primeCents: z.number().int().min(-100_000_000).max(100_000_000),
evolutionCents: z.number().int().min(-100_000_000).max(100_000_000),
}))
.mutation(async ({ input }) => {
if (!isForecastAdjustmentEditable(input.annee, new Date().getFullYear())) {
throw new TRPCError({ code: "FORBIDDEN", message: "Les exercices antérieurs ne sont pas modifiables" });
}
await db.setMasseSalarialeForecastAdjustments(input);
return { success: true };
}),
}),
inventaire: router({