diff --git a/client/src/pages/MasseSalariale.tsx b/client/src/pages/MasseSalariale.tsx index ced3243..fc5c1a8 100644 --- a/client/src/pages/MasseSalariale.tsx +++ b/client/src/pages/MasseSalariale.tsx @@ -6,7 +6,7 @@ import { MasseSalarialeDataState } from "../components/MasseSalarialeDataState"; import { trpc } from "../lib/trpc"; import { getMasseSalarialeUiState } from "../lib/masseSalariale"; import { toast } from "sonner"; -import { Banknote, CalendarDays, CircleAlert, LockKeyhole, ShieldCheck, Users } from "lucide-react"; +import { Banknote, CalendarDays, CircleAlert, FileSpreadsheet, LockKeyhole, ShieldCheck, Upload, Users } from "lucide-react"; const ANNEES = [2025, 2026, 2027] as const; @@ -49,6 +49,8 @@ export default function MasseSalariale() { const [editingKey, setEditingKey] = useState(null); const [primeDraft, setPrimeDraft] = useState(""); const [evolutionDraft, setEvolutionDraft] = useState(""); + const [workbookFile, setWorkbookFile] = useState(null); + const [isImportingWorkbook, setIsImportingWorkbook] = useState(false); const utils = trpc.useUtils(); const { data, isLoading, isError } = trpc.masseSalariale.get.useQuery(); const saveAdjustments = trpc.masseSalariale.setForecastAdjustments.useMutation({ @@ -106,6 +108,32 @@ export default function MasseSalariale() { }); }; + const importWorkbook = async () => { + if (!workbookFile) { + toast.error("Choisissez le fichier Excel de référence avant de lancer l'import."); + return; + } + if (!workbookFile.name.toLowerCase().endsWith(".xlsx") || workbookFile.size > 5 * 1024 * 1024) { + toast.error("Le fichier doit être un .xlsx de 5 Mo maximum."); + return; + } + setIsImportingWorkbook(true); + try { + const payload = new FormData(); + payload.append("file", workbookFile); + const response = await fetch("/api/masse-salariale/previsionnels", { method: "POST", body: payload, credentials: "include" }); + const result = await response.json() as { salaries?: number; remunerations?: number; error?: string }; + if (!response.ok) throw new Error(result.error ?? "L'import a échoué."); + await utils.masseSalariale.get.invalidate(); + setWorkbookFile(null); + toast.success(`Fichier de référence importé : ${result.salaries ?? 0} salarié(s), ${result.remunerations ?? 0} prévision(s).`); + } catch (error) { + toast.error(error instanceof Error ? error.message : "L'import a échoué."); + } finally { + setIsImportingWorkbook(false); + } + }; + return (
setSidebarCollapsed((value) => !value)} /> @@ -124,6 +152,11 @@ export default function MasseSalariale() {
+
+

Fichier prévisionnel de référence

Administrateur uniquement. L'import remplace les prévisions 2025–2027, sans modifier les liasses de paie ni les réels déjà archivés.

+
setWorkbookFile(event.target.files?.[0] ?? null)} disabled={isImportingWorkbook} className="max-w-72 text-xs file:mr-3 file:rounded-md file:border-0 file:bg-emerald-50 file:px-3 file:py-2 file:text-xs file:font-semibold file:text-emerald-700 hover:file:bg-emerald-100" />
+
+
{ANNEES.map((annee) => { const total = totals.get(annee); diff --git a/docs/masse-salariale-previsionnel-reel.md b/docs/masse-salariale-previsionnel-reel.md index 9f0f331..b7fa2d2 100644 --- a/docs/masse-salariale-previsionnel-reel.md +++ b/docs/masse-salariale-previsionnel-reel.md @@ -33,3 +33,5 @@ La liasse de mars 2026 est archivée avec un statut partiel : les fiches non rec La liasse d'avril 2026 est également archivée avec un statut partiel. Le réel annuel conserve donc explicitement sa couverture mensuelle et ne masque pas les extractions incomplètes. Les contrôles automatisés finaux ont réussi : 72 tests couvrant notamment l'accès administrateur, les projections, l'import du modèle, la confidentialité des bulletins et les règles de persistance. Le contrôle TypeScript et le build de production ont également réussi. + +Le téléversement administrateur du fichier modèle a été validé de bout en bout. Le serveur lit le classeur, remplace de manière atomique les seules prévisions 2025–2027 et renvoie uniquement un décompte ; les lignes salariales ne quittent pas l'environnement serveur dans la réponse d'import. diff --git a/server/_core/index.ts b/server/_core/index.ts index 7908909..479c1b1 100644 --- a/server/_core/index.ts +++ b/server/_core/index.ts @@ -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", diff --git a/server/masseSalarialeHttp.test.ts b/server/masseSalarialeHttp.test.ts new file mode 100644 index 0000000..c4c345d --- /dev/null +++ b/server/masseSalarialeHttp.test.ts @@ -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).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).mockResolvedValue({ id: 1, role: "admin" }); + (parseMasseSalarialeWorkbookBuffer as ReturnType).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).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"); + }); +}); diff --git a/server/masseSalarialeHttp.ts b/server/masseSalarialeHttp.ts new file mode 100644 index 0000000..d4859d9 --- /dev/null +++ b/server/masseSalarialeHttp.ts @@ -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." }); + } + }); +} diff --git a/server/masseSalarialeWorkbookImport.ts b/server/masseSalarialeWorkbookImport.ts index 8098770..dd0515f 100644 --- a/server/masseSalarialeWorkbookImport.ts +++ b/server/masseSalarialeWorkbookImport.ts @@ -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(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; +} diff --git a/todo.md b/todo.md index b9c9ba8..7e90914 100644 --- a/todo.md +++ b/todo.md @@ -233,3 +233,4 @@ - [x] Reprendre le brut annuel avec primes calculé par le fichier modèle comme référence prévisionnelle exacte - [ ] Synchroniser le fichier prévisionnel et les liasses mensuelles nécessaires au réel 2026 en recette et production - [x] Archiver les liasses de janvier à avril 2026 fournies dans Salaires avant le calcul réel annuel +- [x] Ajouter un téléversement administrateur du fichier Excel de référence dans Masse salariale