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:
@@ -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é ─────────────────
|
||||
|
||||
47
server/db.ts
47
server/db.ts
@@ -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">,
|
||||
|
||||
75
server/masseSalarialeProjection.test.ts
Normal file
75
server/masseSalarialeProjection.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
112
server/masseSalarialeProjection.ts
Normal file
112
server/masseSalarialeProjection.ts
Normal 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;
|
||||
}
|
||||
68
server/masseSalarialeWorkbookImport.test.ts
Normal file
68
server/masseSalarialeWorkbookImport.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
97
server/masseSalarialeWorkbookImport.ts
Normal file
97
server/masseSalarialeWorkbookImport.ts
Normal 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 };
|
||||
}
|
||||
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user