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

@@ -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<string | null>(null);
const [primeDraft, setPrimeDraft] = useState("");
const [evolutionDraft, setEvolutionDraft] = useState("");
const [workbookFile, setWorkbookFile] = useState<File | null>(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 (
<div className="min-h-screen flex bg-background">
<AppSidebar collapsed={sidebarCollapsed} onToggle={() => setSidebarCollapsed((value) => !value)} />
@@ -124,6 +152,11 @@ export default function MasseSalariale() {
</header>
<div className="flex-1 overflow-auto px-6 py-5 space-y-5">
<section className="bg-card border border-border rounded-xl shadow-sm px-5 py-4 flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
<div className="flex items-start gap-3"><div className="w-9 h-9 rounded-lg bg-emerald-500/15 flex items-center justify-center flex-shrink-0"><FileSpreadsheet className="w-4 h-4 text-emerald-600" /></div><div><h2 className="font-semibold text-foreground">Fichier prévisionnel de référence</h2><p className="text-xs text-muted-foreground mt-0.5">Administrateur uniquement. L'import remplace les prévisions 20252027, sans modifier les liasses de paie ni les réels déjà archivés.</p></div></div>
<div className="flex flex-col sm:flex-row gap-2 sm:items-center"><input aria-label="Fichier Excel de masse salariale" type="file" accept=".xlsx,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" onChange={(event) => 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" /><button onClick={importWorkbook} disabled={!workbookFile || isImportingWorkbook} className="inline-flex items-center justify-center gap-2 rounded-lg bg-emerald-600 px-3 py-2 text-xs font-semibold text-white hover:bg-emerald-700 disabled:cursor-not-allowed disabled:opacity-50"><Upload className="w-3.5 h-3.5" />{isImportingWorkbook ? "Import en cours…" : "Importer le fichier"}</button></div>
</section>
<div className="grid grid-cols-1 xl:grid-cols-3 gap-3">
{ANNEES.map((annee) => {
const total = totals.get(annee);

View File

@@ -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 20252027 et renvoie uniquement un décompte ; les lignes salariales ne quittent pas l'environnement serveur dans la réponse d'import.

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;
}

View File

@@ -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