Checkpoint: Masse salariale enrichie : import Excel administrateur côté serveur, projections annuelles 2025-2027 importées du fichier de référence, réel 2026 exclusivement calculé sur les liasses archivées, conservation des montants de référence et des centimes, éditions limitées aux primes et évolutions de l'exercice courant/futur. 74 tests, TypeScript et build validés.
All checks were successful
Validation applicative / TypeScript, tests et build (push) Successful in 5m21s

This commit is contained in:
Manus
2026-08-31 11:04:30 +00:00
parent 28570234d0
commit 8312cf25fc
7 changed files with 170 additions and 1 deletions

View File

@@ -7,6 +7,7 @@ import { registerOAuthRoutes } from "./oauth";
import { registerStorageProxy } from "./storageProxy";
import { appRouter } from "../routers";
import { registerSalairesHttpRoutes } from "../salairesHttp";
import { registerMasseSalarialeHttpRoutes } from "../masseSalarialeHttp";
import { runDatabaseMigrations } from "../databaseMigrations";
import { createContext } from "./context";
import { serveStatic, setupVite } from "./vite";
@@ -41,6 +42,7 @@ async function startServer() {
registerStorageProxy(app);
registerOAuthRoutes(app);
registerSalairesHttpRoutes(app);
registerMasseSalarialeHttpRoutes(app);
// tRPC API
app.use(
"/api/trpc",

View File

@@ -0,0 +1,58 @@
import express from "express";
import request from "supertest";
import { beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("./_core/sdk", () => ({ sdk: { authenticateRequest: vi.fn() } }));
vi.mock("./db", () => ({ importMasseSalariale: vi.fn() }));
vi.mock("./masseSalarialeWorkbookImport", () => ({ parseMasseSalarialeWorkbookBuffer: vi.fn() }));
import { sdk } from "./_core/sdk";
import { importMasseSalariale } from "./db";
import { registerMasseSalarialeHttpRoutes } from "./masseSalarialeHttp";
import { parseMasseSalarialeWorkbookBuffer } from "./masseSalarialeWorkbookImport";
function makeApp() {
const app = express();
registerMasseSalarialeHttpRoutes(app);
return app;
}
describe("route HTTP d'import Masse salariale", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("interdit l'import Excel à un profil non administrateur", async () => {
(sdk.authenticateRequest as ReturnType<typeof vi.fn>).mockResolvedValue({ id: 2, role: "standard" });
await request(makeApp())
.post("/api/masse-salariale/previsionnels")
.attach("file", Buffer.from("PK\x03\x04 fichier Excel"), { filename: "masse.xlsx", contentType: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" })
.expect(403);
expect(importMasseSalariale).not.toHaveBeenCalled();
});
it("importe un fichier Excel administrateur sans transmettre son contenu au client", async () => {
(sdk.authenticateRequest as ReturnType<typeof vi.fn>).mockResolvedValue({ id: 1, role: "admin" });
(parseMasseSalarialeWorkbookBuffer as ReturnType<typeof vi.fn>).mockReturnValue({
salaries: [{ matricule: "000001", nom: "NOM", prenom: "PRENOM", poste: "Poste", dateEmbauche: "2024-01-01" }],
remunerations: [{
matricule: "000001", annee: 2026, nature: "previsionnel", salaireAnnuelBrutHorsPrimesCents: 100_000,
salaireAnnuelBrutAvecPrimesCents: 110_000, salaireMensuelBrutHorsPrimesCents: 8_333,
tauxChargeBps: 4_500, primeAstreinteCents: 10_000, primeCents: 0, primeReferenceCents: 0,
evolutionCents: 0, evolutionReferenceCents: 0, periodeReference: "2026-08", statut: "previsionnel", source: "fichier_modele_2026-08-28",
}],
});
(importMasseSalariale as ReturnType<typeof vi.fn>).mockResolvedValue({ salaries: 1, remunerations: 1 });
const response = await request(makeApp())
.post("/api/masse-salariale/previsionnels")
.attach("file", Buffer.from("PK\x03\x04 fichier Excel"), { filename: "masse.xlsx", contentType: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" })
.expect(200);
expect(importMasseSalariale).toHaveBeenCalledWith(expect.any(Array), expect.any(Array), { replaceForecastYears: [2025, 2026, 2027] });
expect(response.body).toEqual({ salaries: 1, remunerations: 1, years: [2025, 2026, 2027] });
expect(JSON.stringify(response.body)).not.toContain("000001");
});
});

View File

@@ -0,0 +1,52 @@
import type { Express, Request, Response } from "express";
import multer from "multer";
import { sdk } from "./_core/sdk";
import { importMasseSalariale } from "./db";
import { parseMasseSalarialeWorkbookBuffer } from "./masseSalarialeWorkbookImport";
const MAX_WORKBOOK_BYTES = 5 * 1024 * 1024;
const upload = multer({ storage: multer.memoryStorage(), limits: { fileSize: MAX_WORKBOOK_BYTES, files: 1 } });
async function getAdminUser(req: Request, res: Response) {
try {
const user = await sdk.authenticateRequest(req);
if (user.role === "admin") return user;
res.status(403).json({ error: "Accès réservé aux administrateurs." });
} catch {
res.status(401).json({ error: "Authentification requise." });
}
return null;
}
function isXlsx(file: Express.Multer.File | undefined): file is Express.Multer.File {
if (!file) return false;
return file.originalname.toLowerCase().endsWith(".xlsx")
&& file.buffer.subarray(0, 2).toString("ascii") === "PK";
}
/**
* Cette route est volontairement limitée au transfert binaire du classeur.
* Elle ne renvoie jamais les lignes salariales : la page relit ensuite son
* agrégat tRPC administrateur.
*/
export function registerMasseSalarialeHttpRoutes(app: Express) {
app.post("/api/masse-salariale/previsionnels", upload.single("file"), async (req, res) => {
const user = await getAdminUser(req, res);
if (!user) return;
if (!isXlsx(req.file)) {
res.status(400).json({ error: "Transmettez un fichier .xlsx conforme (5 Mo maximum)." });
return;
}
try {
const parsed = parseMasseSalarialeWorkbookBuffer(req.file.buffer);
const result = await importMasseSalariale(parsed.salaries, parsed.remunerations, {
replaceForecastYears: [2025, 2026, 2027],
});
res.json({ salaries: result.salaries, remunerations: result.remunerations, years: [2025, 2026, 2027] });
} catch (error) {
console.error("[Masse salariale] Échec import prévisionnel", error instanceof Error ? error.message : "erreur inconnue");
res.status(422).json({ error: "Le fichier n'a pas pu être importé. Les prévisions existantes sont conservées." });
}
});
}

View File

@@ -1,4 +1,5 @@
import type { MasseSalarialeImportRemuneration, MasseSalarialeImportSalarie } from "./db";
import XLSX from "xlsx";
type SpreadsheetValue = string | number | Date | null | undefined;
@@ -95,3 +96,23 @@ export function parseMasseSalarialeWorkbookRows(rows: SpreadsheetValue[][]): Mas
return { salaries, remunerations };
}
/**
* Lit le fichier exclusivement côté serveur, afin que les données de masse
* salariale ne soient ni transformées ni renvoyées par le navigateur.
*/
export function parseMasseSalarialeWorkbookBuffer(buffer: Buffer): MasseSalarialeWorkbookImport {
const workbook = XLSX.read(buffer, { type: "buffer", cellDates: true });
const sheet = workbook.Sheets["Masse salariale"];
if (!sheet) throw new Error("Feuille Masse salariale introuvable");
const rows = XLSX.utils.sheet_to_json<SpreadsheetValue[]>(sheet, {
header: 1,
raw: true,
defval: null,
});
const parsed = parseMasseSalarialeWorkbookRows(rows);
if (parsed.salaries.length === 0 || parsed.remunerations.length === 0) {
throw new Error("Le fichier ne contient aucune prévision exploitable");
}
return parsed;
}