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

@@ -74,6 +74,7 @@ vi.mock("./db", async (importOriginal) => {
getMasseSalariale: vi.fn(),
importMasseSalariale: vi.fn(),
setMasseSalarialeEvolution: vi.fn(),
setMasseSalarialeForecastAdjustments: vi.fn(),
getSalairesMensuels: vi.fn(),
};
});
@@ -356,11 +357,16 @@ describe("masseSalariale", () => {
it("retourne les données uniquement pour un administrateur", async () => {
const { db } = await import("./db").then(m => ({ db: m }));
const source = { salaries: [], remunerations: [], evolutions: [] };
const source = { salaries: [], remunerations: [], evolutions: [], bulletinsMensuels: [] };
(db.getMasseSalariale as ReturnType<typeof vi.fn>).mockResolvedValue(source);
const { ctx } = createAuthCtx({ role: "admin" });
await expect(appRouter.createCaller(ctx).masseSalariale.get()).resolves.toEqual(source);
await expect(appRouter.createCaller(ctx).masseSalariale.get()).resolves.toEqual({
salaries: [],
forecasts: [],
actuals: [],
currentYear: new Date().getFullYear(),
});
});
it("interdit la consultation à un profil standard", async () => {
@@ -369,6 +375,43 @@ describe("masseSalariale", () => {
await expect(appRouter.createCaller(ctx).masseSalariale.get()).rejects.toThrow();
});
it("construit le réel annuel uniquement depuis les bulletins mensuels archivés", async () => {
const { db } = await import("./db").then(m => ({ db: m }));
(db.getMasseSalariale as ReturnType<typeof vi.fn>).mockResolvedValue({
salaries: [{ id: 7, matricule: "000001", nom: "NOM", prenom: "PRENOM", poste: "Poste", dateEmbauche: "2024-01-01" }],
remunerations: [{
salarieId: 7,
annee: 2026,
salaireAnnuelBrutHorsPrimesCents: 1_000_000,
salaireAnnuelBrutAvecPrimesCents: 1_200_000,
salaireMensuelBrutHorsPrimesCents: 83_333,
tauxChargeBps: 4_500,
primeAstreinteCents: 200_000,
primeCents: 0,
primeReferenceCents: 0,
evolutionCents: 0,
evolutionReferenceCents: 0,
}],
evolutions: [],
bulletinsMensuels: [
{ annee: 2026, mois: 1, matricule: "000001", brutMensuelCents: 100_000, brutAvecPrimesCents: 120_000, primeAstreinteCents: 20_000 },
{ annee: 2026, mois: 2, matricule: "000001", brutMensuelCents: 100_000, brutAvecPrimesCents: 110_000, primeAstreinteCents: 0 },
],
});
const { ctx } = createAuthCtx({ role: "admin" });
const result = await appRouter.createCaller(ctx).masseSalariale.get();
expect(result.actuals).toEqual([expect.objectContaining({
salarieId: 7,
annee: 2026,
brutAvecPrimesCents: 230_000,
brutHorsAstreinteCents: 210_000,
moisDisponibles: [1, 2],
origine: "liasses",
})]);
});
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 });
@@ -394,7 +437,15 @@ describe("masseSalariale", () => {
await expect(appRouter.createCaller(ctx).masseSalariale.import({ salaries, remunerations }))
.resolves.toEqual({ salaries: 1, remunerations: 1 });
expect(db.importMasseSalariale).toHaveBeenCalledWith(salaries, remunerations);
expect(db.importMasseSalariale).toHaveBeenCalledWith(salaries, [{
...remunerations[0],
nature: "previsionnel",
primeAstreinteCents: 0,
primeCents: 0,
evolutionCents: 0,
primeReferenceCents: 0,
evolutionReferenceCents: 0,
}]);
});
it("interdit l'import de rémunérations à un profil standard", async () => {
@@ -434,6 +485,52 @@ describe("masseSalariale", () => {
})).rejects.toThrow();
expect(db.setMasseSalarialeEvolution).not.toHaveBeenCalled();
});
it("autorise un administrateur à modifier uniquement les primes et évolutions de l'exercice courant", async () => {
const { db } = await import("./db").then(m => ({ db: m }));
const { ctx } = createAuthCtx({ role: "admin" });
const anneeCourante = new Date().getFullYear();
await expect(appRouter.createCaller(ctx).masseSalariale.setForecastAdjustments({
salarieId: 7,
annee: anneeCourante,
primeCents: 12_500,
evolutionCents: 20_000,
})).resolves.toEqual({ success: true });
expect(db.setMasseSalarialeForecastAdjustments).toHaveBeenCalledWith({
salarieId: 7,
annee: anneeCourante,
primeCents: 12_500,
evolutionCents: 20_000,
});
});
it("interdit l'édition prévisionnelle d'un exercice antérieur", async () => {
const { db } = await import("./db").then(m => ({ db: m }));
const { ctx } = createAuthCtx({ role: "admin" });
await expect(appRouter.createCaller(ctx).masseSalariale.setForecastAdjustments({
salarieId: 7,
annee: new Date().getFullYear() - 1,
primeCents: 12_500,
evolutionCents: 20_000,
})).rejects.toThrow();
expect(db.setMasseSalarialeForecastAdjustments).not.toHaveBeenCalled();
});
it("interdit l'édition prévisionnelle à un profil standard", async () => {
const { db } = await import("./db").then(m => ({ db: m }));
const { ctx } = createAuthCtx({ role: "standard" });
await expect(appRouter.createCaller(ctx).masseSalariale.setForecastAdjustments({
salarieId: 7,
annee: new Date().getFullYear(),
primeCents: 0,
evolutionCents: 0,
})).rejects.toThrow();
expect(db.setMasseSalarialeForecastAdjustments).not.toHaveBeenCalled();
});
});
// ─── Tests Salaires : sonde de chiffrement et confidentialité ─────────────────

View File

@@ -1,4 +1,4 @@
import { and, asc, desc, eq } from "drizzle-orm";
import { and, asc, desc, eq, inArray } from "drizzle-orm";
import { drizzle } from "drizzle-orm/mysql2";
import {
capexLignes,
@@ -478,12 +478,20 @@ export type MasseSalarialeImportRemuneration = Omit<
export async function getMasseSalariale() {
const db = await getDb();
if (!db) throw new Error("Database not available");
const [salaries, remunerations, evolutions] = await Promise.all([
const [salaries, remunerations, evolutions, bulletinsMensuels] = await Promise.all([
db.select().from(masseSalarialeSalaries).orderBy(asc(masseSalarialeSalaries.nom), asc(masseSalarialeSalaries.prenom)),
db.select().from(masseSalarialeRemunerations),
db.select().from(masseSalarialeEvolutions),
db.select({
annee: salairesLiasses.annee,
mois: salairesLiasses.mois,
matricule: salairesBulletins.matricule,
brutMensuelCents: salairesBulletins.brutMensuelCents,
brutAvecPrimesCents: salairesBulletins.brutAvecPrimesCents,
primeAstreinteCents: salairesBulletins.primeAstreinteCents,
}).from(salairesBulletins).innerJoin(salairesLiasses, eq(salairesBulletins.liasseId, salairesLiasses.id)),
]);
return { salaries, remunerations, evolutions };
return { salaries, remunerations, evolutions, bulletinsMensuels };
}
/**
@@ -494,6 +502,7 @@ export async function getMasseSalariale() {
export async function importMasseSalariale(
salaries: MasseSalarialeImportSalarie[],
remunerations: MasseSalarialeImportRemuneration[],
options: { replaceForecastYears?: number[] } = {},
) {
const db = await getDb();
if (!db) throw new Error("Database not available");
@@ -520,6 +529,13 @@ export async function importMasseSalariale(
.from(masseSalarialeSalaries);
const idsByMatricule = new Map(persistedSalaries.map((row) => [row.matricule, row.id]));
if (options.replaceForecastYears?.length) {
await tx.delete(masseSalarialeRemunerations).where(and(
inArray(masseSalarialeRemunerations.annee, options.replaceForecastYears),
eq(masseSalarialeRemunerations.nature, "previsionnel"),
));
}
for (const remuneration of remunerations) {
const salarieId = idsByMatricule.get(remuneration.matricule);
if (!salarieId) {
@@ -535,6 +551,12 @@ export async function importMasseSalariale(
salaireAnnuelBrutAvecPrimesCents: values.salaireAnnuelBrutAvecPrimesCents,
salaireMensuelBrutHorsPrimesCents: values.salaireMensuelBrutHorsPrimesCents,
tauxChargeBps: values.tauxChargeBps,
nature: values.nature,
primeAstreinteCents: values.primeAstreinteCents,
primeCents: values.primeCents,
evolutionCents: values.evolutionCents,
primeReferenceCents: values.primeReferenceCents,
evolutionReferenceCents: values.evolutionReferenceCents,
periodeReference: values.periodeReference,
statut: values.statut,
source: values.source,
@@ -545,6 +567,25 @@ export async function importMasseSalariale(
});
}
/** Met à jour uniquement les deux hypothèses qui peuvent être modifiées. */
export async function setMasseSalarialeForecastAdjustments(input: {
salarieId: number;
annee: number;
primeCents: number;
evolutionCents: number;
}) {
const db = await getDb();
if (!db) throw new Error("Database not available");
await db.update(masseSalarialeRemunerations).set({
primeCents: input.primeCents,
evolutionCents: input.evolutionCents,
}).where(and(
eq(masseSalarialeRemunerations.salarieId, input.salarieId),
eq(masseSalarialeRemunerations.annee, input.annee),
eq(masseSalarialeRemunerations.nature, "previsionnel"),
));
}
/** Sauvegarde l'évolution manuelle sans dépendre de l'existence d'un bulletin. */
export async function setMasseSalarialeEvolution(
input: Pick<InsertMasseSalarialeEvolution, "salarieId" | "annee" | "evolutionSalarialeBps" | "createdBy">,

View File

@@ -0,0 +1,75 @@
import { describe, expect, it } from "vitest";
import {
buildAnnualForecasts,
buildAnnualPayrollActuals,
isForecastAdjustmentEditable,
} from "./masseSalarialeProjection";
describe("projection prévisionnelle et réelle de masse salariale", () => {
it("duplique les données closes 2025 entre prévisionnel et réel", () => {
const forecasts = buildAnnualForecasts([
{
annee: 2025,
brutHorsAstreinteReferenceCents: 108_000,
brutAvecPrimesReferenceCents: 120_000,
primeAstreinteCents: 12_000,
primeCents: 0,
primeReferenceCents: 0,
evolutionCents: 0,
evolutionReferenceCents: 0,
tauxChargeBps: 4_500,
},
]);
expect(forecasts.get(2025)).toMatchObject({
brutHorsAstreinteCents: 108_000,
brutAvecPrimesCents: 120_000,
brutMensuelHorsAstreinteCents: 9_000,
tauxChargeBps: 4_500,
});
});
it("recalcule le prévisionnel futur à partir de la référence importée et des seules modifications manuelles", () => {
const forecasts = buildAnnualForecasts([
{
annee: 2026,
brutHorsAstreinteReferenceCents: 114_000,
brutAvecPrimesReferenceCents: 119_000,
primeAstreinteCents: 6_000,
primeCents: 1_500,
primeReferenceCents: 1_000,
evolutionCents: 6_000,
evolutionReferenceCents: 5_000,
tauxChargeBps: 4_600,
},
]);
expect(forecasts.get(2026)).toMatchObject({
brutHorsAstreinteCents: 115_500,
brutAvecPrimesCents: 120_500,
brutMensuelHorsAstreinteCents: 9_625,
});
});
it("calcule le réel 2026 à partir des seuls bulletins mensuels archivés, sans extrapolation", () => {
const actuals = buildAnnualPayrollActuals([
{ annee: 2026, mois: 5, matricule: "test-1", brutMensuelCents: 10_000, brutAvecPrimesCents: 12_000, primeAstreinteCents: 1_000 },
{ annee: 2026, mois: 6, matricule: "test-1", brutMensuelCents: 10_000, brutAvecPrimesCents: 11_000, primeAstreinteCents: 0 },
]);
expect(actuals.get("test-1")?.get(2026)).toEqual({
brutHorsAstreinteCents: 22_000,
brutAvecPrimesCents: 23_000,
brutMensuelHorsAstreinteCents: 11_000,
primeAstreinteCents: 1_000,
primeComplementaireCents: 2_000,
moisDisponibles: [5, 6],
});
});
it("n'autorise la modification des primes et évolutions qu'à partir de l'année courante", () => {
expect(isForecastAdjustmentEditable(2025, 2026)).toBe(false);
expect(isForecastAdjustmentEditable(2026, 2026)).toBe(true);
expect(isForecastAdjustmentEditable(2027, 2026)).toBe(true);
});
});

View File

@@ -0,0 +1,112 @@
/**
* Calculs purs de la fenêtre Masse salariale. Les montants sont conservés en
* centimes afin que les projections et cumuls ne subissent aucun arrondi
* intermédiaire. Les réels ne sont jamais extrapolés : ils cumulent seulement
* les liasses mensuelles effectivement archivées.
*/
export type AnnualForecastInput = {
annee: number;
brutHorsAstreinteReferenceCents: number;
brutAvecPrimesReferenceCents: number;
primeAstreinteCents: number;
primeCents: number;
primeReferenceCents: number;
evolutionCents: number;
evolutionReferenceCents: number;
tauxChargeBps: number | null;
};
export type AnnualProjection = {
brutHorsAstreinteCents: number;
brutAvecPrimesCents: number;
brutMensuelHorsAstreinteCents: number;
primeAstreinteCents: number;
primeComplementaireCents: number;
evolutionCents: number;
tauxChargeBps: number | null;
};
export type PayrollMonthlyInput = {
annee: number;
mois: number;
matricule: string;
brutMensuelCents: number;
brutAvecPrimesCents: number;
primeAstreinteCents: number;
};
export type PayrollAnnualActual = Omit<AnnualProjection, "evolutionCents" | "tauxChargeBps"> & {
moisDisponibles: number[];
};
/**
* Restitue exactement la référence importée, puis applique seulement les
* écarts manuels des primes et évolutions. Ainsi, un import Excel conserve ses
* calculs initiaux tandis que toute correction autorisée est automatiquement
* répercutée sur le brut prévisionnel dérivé.
*/
export function buildAnnualForecasts(inputs: AnnualForecastInput[]): Map<number, AnnualProjection> {
const forecasts = new Map<number, AnnualProjection>();
for (const current of [...inputs].sort((a, b) => a.annee - b.annee)) {
const adjustmentDelta =
(current.primeCents - current.primeReferenceCents)
+ (current.evolutionCents - current.evolutionReferenceCents);
const brutHorsAstreinteCents = current.brutHorsAstreinteReferenceCents + adjustmentDelta;
const brutAvecPrimesCents = current.brutAvecPrimesReferenceCents + adjustmentDelta;
forecasts.set(current.annee, {
brutHorsAstreinteCents,
brutAvecPrimesCents,
brutMensuelHorsAstreinteCents: Math.round(brutHorsAstreinteCents / 12),
primeAstreinteCents: current.primeAstreinteCents,
primeComplementaireCents: current.primeCents,
evolutionCents: current.evolutionCents,
tauxChargeBps: current.tauxChargeBps,
});
}
return forecasts;
}
/**
* Agrège les bulletins archivés par salarié et année. Le brut hors astreinte
* ne retire que les primes d'astreinte : les autres éléments compris dans le
* brut (activité, responsabilité, vacances…) sont donc préservés.
*/
export function buildAnnualPayrollActuals(inputs: PayrollMonthlyInput[]): Map<string, Map<number, PayrollAnnualActual>> {
const grouped = new Map<string, Map<number, PayrollMonthlyInput[]>>();
for (const input of inputs) {
const byYear = grouped.get(input.matricule) ?? new Map<number, PayrollMonthlyInput[]>();
byYear.set(input.annee, [...(byYear.get(input.annee) ?? []), input]);
grouped.set(input.matricule, byYear);
}
const actuals = new Map<string, Map<number, PayrollAnnualActual>>();
for (const [matricule, byYear] of Array.from(grouped.entries())) {
const projections = new Map<number, PayrollAnnualActual>();
for (const [annee, monthlyRows] of Array.from(byYear.entries())) {
const brutAvecPrimesCents = monthlyRows.reduce((sum, row) => sum + row.brutAvecPrimesCents, 0);
const primeAstreinteCents = monthlyRows.reduce((sum, row) => sum + row.primeAstreinteCents, 0);
const brutHorsAstreinteCents = brutAvecPrimesCents - primeAstreinteCents;
const primeComplementaireCents = monthlyRows.reduce(
(sum, row) => sum + (row.brutAvecPrimesCents - row.brutMensuelCents - row.primeAstreinteCents),
0,
);
const moisDisponibles = Array.from(new Set<number>(monthlyRows.map((row) => row.mois))).sort((a, b) => a - b);
projections.set(annee, {
brutHorsAstreinteCents,
brutAvecPrimesCents,
brutMensuelHorsAstreinteCents: Math.round(brutHorsAstreinteCents / moisDisponibles.length),
primeAstreinteCents,
primeComplementaireCents,
moisDisponibles,
});
}
actuals.set(matricule, projections);
}
return actuals;
}
/** Les ajustements de prévision sont bloqués sur les exercices clôturés. */
export function isForecastAdjustmentEditable(annee: number, currentYear: number): boolean {
return annee >= currentYear;
}

View File

@@ -0,0 +1,68 @@
import { describe, expect, it } from "vitest";
import { parseMasseSalarialeWorkbookRows } from "./masseSalarialeWorkbookImport";
describe("import du modèle Masse salariale", () => {
it("importe les prévisionnels sans confondre taux, primes et évolutions", () => {
const rows: Array<Array<string | number | Date | null>> = Array.from({ length: 5 }, () => []);
rows.push([
null,
"000001",
"NOM",
"PRENOM",
"Poste",
new Date("2024-01-01T00:00:00.000Z"),
35_000,
37_000,
2_000,
2_916.67,
0.45,
1_000,
36_000,
39_000,
2_500,
3_000,
0.46,
1_500,
40_000,
43_000,
2_000,
3_333.33,
null,
2_000,
0.5,
]);
const parsed = parseMasseSalarialeWorkbookRows(rows);
expect(parsed.salaries).toHaveLength(1);
expect(parsed.remunerations).toHaveLength(3);
expect(parsed.remunerations.find((row) => row.annee === 2026)).toMatchObject({
salaireAnnuelBrutHorsPrimesCents: 3_600_000,
salaireAnnuelBrutAvecPrimesCents: 3_900_000,
primeAstreinteCents: 250_000,
evolutionCents: 150_000,
tauxChargeBps: 4_600,
primeCents: 0,
});
expect(parsed.remunerations.find((row) => row.annee === 2027)).toMatchObject({
primeCents: 50,
evolutionCents: 200_000,
tauxChargeBps: null,
});
});
it("accepte les lignes SheetJS sans cellule A vide et les dates Excel numériques", () => {
const rows: Array<Array<string | number | Date | null>> = Array.from({ length: 5 }, () => []);
rows.push([
"000002", "NOM", "PRENOM", "Poste", 45_292,
35_000, 37_000, 2_000, 2_916.67, 0.45, 1_000,
36_000, 39_000, 2_500, 3_000, 0.46, 1_500,
40_000, 43_000, 2_000, 3_333.33, null, 2_000, 0.5,
]);
const parsed = parseMasseSalarialeWorkbookRows(rows);
expect(parsed.salaries).toMatchObject([{ matricule: "000002", dateEmbauche: "2024-01-01" }]);
expect(parsed.remunerations).toHaveLength(3);
});
});

View File

@@ -0,0 +1,97 @@
import type { MasseSalarialeImportRemuneration, MasseSalarialeImportSalarie } from "./db";
type SpreadsheetValue = string | number | Date | null | undefined;
function cents(value: SpreadsheetValue): number {
if (typeof value === "number") return Math.round(value * 100);
if (typeof value !== "string") return 0;
const normalized = value.replace(/[\s\u00a0]/g, "").replace(",", ".");
const parsed = Number(normalized);
return Number.isFinite(parsed) ? Math.round(parsed * 100) : 0;
}
function bps(value: SpreadsheetValue): number | null {
if (value === null || value === undefined || value === "") return null;
const numeric = typeof value === "number" ? value : Number(String(value).replace(",", "."));
return Number.isFinite(numeric) ? Math.round(numeric * 10_000) : null;
}
function isoDate(value: SpreadsheetValue): string | null {
if (value instanceof Date && !Number.isNaN(value.valueOf())) return value.toISOString().slice(0, 10);
if (typeof value === "number" && Number.isFinite(value)) {
const excelDate = new Date(Date.UTC(1899, 11, 30) + Math.round(value * 86_400_000));
return Number.isNaN(excelDate.valueOf()) ? null : excelDate.toISOString().slice(0, 10);
}
if (typeof value === "string" && /^\d{4}-\d{2}-\d{2}$/.test(value)) return value;
if (typeof value === "string" && /^\d{2}\/\d{2}\/\d{4}$/.test(value)) {
const [day, month, year] = value.split("/");
return `${year}-${month}-${day}`;
}
return null;
}
function text(value: SpreadsheetValue): string {
return typeof value === "string" || typeof value === "number" ? String(value).trim() : "";
}
export type MasseSalarialeWorkbookImport = {
salaries: MasseSalarialeImportSalarie[];
remunerations: MasseSalarialeImportRemuneration[];
};
/**
* Convertit les lignes de la feuille modèle (index Excel 1, en-tête sur la
* ligne 5) sans évaluer de formules côté application : les valeurs calculées
* et sauvegardées par le fichier fourni servent de référence importée.
*/
export function parseMasseSalarialeWorkbookRows(rows: SpreadsheetValue[][]): MasseSalarialeWorkbookImport {
const salaries: MasseSalarialeImportSalarie[] = [];
const remunerations: MasseSalarialeImportRemuneration[] = [];
for (const row of rows.slice(5)) {
// SheetJS supprime la cellule A vide de certaines lignes. On repère donc
// la présence effective de cette cellule plutôt que de supposer un index.
const originalColumn = (excelColumn: number) => row[excelColumn + (row[0] === null || row[0] === undefined ? 0 : -1)];
const matricule = text(originalColumn(1));
const nom = text(originalColumn(2));
const prenom = text(originalColumn(3));
const poste = text(originalColumn(4));
const dateEmbauche = isoDate(originalColumn(5));
if (!matricule || !nom || !prenom || !poste || !dateEmbauche) continue;
salaries.push({ matricule, nom, prenom, poste, dateEmbauche });
const addForecast = (annee: number, startIndex: number, tauxChargeBps: number | null, periodeReference: string) => {
const brutHorsAstreinte = cents(originalColumn(startIndex));
const brutAvecPrimes = cents(originalColumn(startIndex + 1));
const primeAstreinte = cents(originalColumn(startIndex + 2));
const brutMensuel = cents(originalColumn(startIndex + 3));
const evolution = cents(originalColumn(startIndex + 5));
const prime = annee === 2027 ? cents(originalColumn(startIndex + 6)) : 0;
if (brutHorsAstreinte === 0 && brutAvecPrimes === 0 && brutMensuel === 0) return;
remunerations.push({
matricule,
annee,
nature: "previsionnel",
salaireAnnuelBrutHorsPrimesCents: brutHorsAstreinte,
salaireAnnuelBrutAvecPrimesCents: brutAvecPrimes,
salaireMensuelBrutHorsPrimesCents: brutMensuel,
tauxChargeBps,
primeAstreinteCents: primeAstreinte,
primeCents: prime,
evolutionCents: evolution,
primeReferenceCents: prime,
evolutionReferenceCents: evolution,
periodeReference,
statut: annee === 2025 ? "annuel" : "previsionnel",
source: "fichier_modele_2026-08-28",
});
};
addForecast(2025, 6, bps(originalColumn(10)), "2025-12");
addForecast(2026, 12, bps(originalColumn(16)), "2026-08");
addForecast(2027, 18, null, "2027-01");
}
return { salaries, remunerations };
}

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({