Compare commits

...

2 Commits

29 changed files with 6181 additions and 3 deletions

View File

@@ -20,6 +20,7 @@ import SoinsSante from "./pages/SoinsSante";
import StExupery from "./pages/StExupery";
import TableauBordFinance from "./pages/TableauBordFinance";
import MasseSalariale from "./pages/MasseSalariale";
import Salaires from "./pages/Salaires";
import { useAuth } from "./_core/hooks/useAuth";
import { DashboardLayoutSkeleton } from "./components/DashboardLayoutSkeleton";
@@ -96,6 +97,9 @@ function Router() {
<Route path="/masse-salariale">
<AdminRoute component={MasseSalariale} />
</Route>
<Route path="/salaires">
<AdminRoute component={Salaires} />
</Route>
<Route path="/404" component={NotFound} />
<Route component={NotFound} />

View File

@@ -135,6 +135,7 @@ const NAV_SECTIONS: NavSection[] = [
children: [
{ id: 'santinova', label: 'Vue SANTINOVA', icon: Heart, path: '/santinova' },
{ id: 'masse-salariale', label: 'Masse salariale', icon: Banknote, path: '/masse-salariale', adminOnly: true },
{ id: 'salaires', label: 'Salaires', icon: Banknote, path: '/salaires', adminOnly: true },
],
},
{

View File

@@ -0,0 +1,226 @@
import { useMemo, useRef, useState } from "react";
import { toast } from "sonner";
import {
Archive,
Banknote,
CalendarDays,
CircleAlert,
FileText,
Loader2,
LockKeyhole,
ShieldCheck,
TrendingDown,
TrendingUp,
Upload,
Users,
} from "lucide-react";
import { AppSidebar } from "../components/AppSidebar";
import { trpc } from "../lib/trpc";
const MONTHS = [
"Janvier", "Février", "Mars", "Avril", "Mai", "Juin",
"Juillet", "Août", "Septembre", "Octobre", "Novembre", "Décembre",
];
function formatEuros(cents?: number | null) {
if (cents === null || cents === undefined) return "—";
return new Intl.NumberFormat("fr-FR", {
style: "currency",
currency: "EUR",
minimumFractionDigits: 0,
maximumFractionDigits: 0,
}).format(cents / 100);
}
function formatPercent(percent?: number | null) {
if (percent === null || percent === undefined) return "—";
return `${percent.toLocaleString("fr-FR", { maximumFractionDigits: 2 })} %`;
}
function extractionStatusLabel(status: string) {
if (status === "ready") return { label: "Indexé", className: "bg-emerald-100 text-emerald-700" };
if (status === "partiel") return { label: "Partiel", className: "bg-amber-100 text-amber-800" };
return { label: "À contrôler", className: "bg-rose-100 text-rose-700" };
}
/**
* Module administrateur : les données visibles sont l'index métier réduit des
* bulletins. Les PDFs restent chiffrés dans le stockage persistant et ne sont
* servis qu'après un nouveau contrôle serveur de la session.
*/
export default function Salaires() {
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
const now = useMemo(() => new Date(), []);
const [annee, setAnnee] = useState(now.getFullYear());
const [mois, setMois] = useState(now.getMonth() + 1);
const [selectedFile, setSelectedFile] = useState<File | null>(null);
const [isUploading, setIsUploading] = useState(false);
const [reindexingId, setReindexingId] = useState<number | null>(null);
const inputRef = useRef<HTMLInputElement>(null);
const utils = trpc.useUtils();
const filters = useMemo(() => ({ annee, mois }), [annee, mois]);
const { data, isLoading, isError } = trpc.salaires.list.useQuery(filters);
const { data: periods = [] } = trpc.salaires.periods.useQuery();
const { data: security } = trpc.salaires.getSecurityStatus.useQuery();
const years = useMemo(() => {
const options = new Set([now.getFullYear(), ...periods.map((period) => period.annee)]);
return Array.from(options).sort((a, b) => b - a);
}, [now, periods]);
const uploadLiasse = async () => {
if (!selectedFile) {
toast.error("Sélectionnez une liasse PDF avant l'import.");
return;
}
if (selectedFile.type !== "application/pdf" && !selectedFile.name.toLowerCase().endsWith(".pdf")) {
toast.error("Le fichier sélectionné doit être un PDF.");
return;
}
if (!security?.ready) {
toast.error("Le chiffrement sécurisé est indisponible : l'import est bloqué.");
return;
}
const form = new FormData();
form.append("annee", String(annee));
form.append("mois", String(mois));
form.append("file", selectedFile);
setIsUploading(true);
try {
const response = await fetch("/api/salaires/liasses", { method: "POST", body: form, credentials: "include" });
const payload = await response.json().catch(() => ({})) as { bulletins?: number; avertissement?: string | null; error?: string };
if (!response.ok) throw new Error(payload.error ?? "L'import n'a pas pu être effectué.");
toast.success(`Liasse archivée de façon chiffrée — ${payload.bulletins ?? 0} bulletin(s) indexé(s).`);
if (payload.avertissement) toast.warning(payload.avertissement);
setSelectedFile(null);
if (inputRef.current) inputRef.current.value = "";
await Promise.all([utils.salaires.list.invalidate(), utils.salaires.periods.invalidate()]);
} catch (error) {
toast.error(error instanceof Error ? error.message : "L'import sécurisé a échoué.");
} finally {
setIsUploading(false);
}
};
const reindexLiasse = async (liasseId: number) => {
setReindexingId(liasseId);
try {
const response = await fetch(`/api/salaires/liasses/${liasseId}/reindex`, { method: "POST", credentials: "include" });
const payload = await response.json().catch(() => ({})) as { bulletins?: number; avertissement?: string | null; error?: string };
if (!response.ok) throw new Error(payload.error ?? "La réindexation a échoué.");
toast.success(`Index de la liasse actualisé — ${payload.bulletins ?? 0} bulletin(s) reconnu(s).`);
if (payload.avertissement) toast.warning(payload.avertissement);
await Promise.all([utils.salaires.list.invalidate(), utils.salaires.periods.invalidate()]);
} catch (error) {
toast.error(error instanceof Error ? error.message : "La réindexation a échoué.");
} finally {
setReindexingId(null);
}
};
const hasExistingPeriod = periods.some((period) => period.annee === annee && period.mois === mois);
return (
<div className="min-h-screen flex bg-background">
<AppSidebar collapsed={sidebarCollapsed} onToggle={() => setSidebarCollapsed((value) => !value)} />
<main className="flex-1 min-w-0 flex flex-col overflow-hidden">
<header className="bg-card border-b border-border px-6 py-4 shrink-0">
<div className="flex flex-wrap items-start justify-between gap-4">
<div className="flex items-center gap-3 min-w-0">
<div className="w-10 h-10 rounded-xl bg-emerald-500/15 flex items-center justify-center shrink-0">
<Banknote className="w-5 h-5 text-emerald-600" />
</div>
<div className="min-w-0">
<h1 className="text-xl font-bold text-foreground" style={{ fontFamily: "Sora, sans-serif" }}>Salaires SANTINOVA</h1>
<p className="text-sm text-muted-foreground mt-0.5">Liasses mensuelles chiffrées, index métier et comparatif M-1</p>
</div>
</div>
<div className="flex items-center gap-2 text-xs text-emerald-700 bg-emerald-50 border border-emerald-200 rounded-full px-3 py-1.5 whitespace-nowrap">
<ShieldCheck className="w-3.5 h-3.5" /> Accès administrateur
</div>
</div>
</header>
<div className="flex-1 overflow-auto px-6 py-5 space-y-5">
{!security?.ready && (
<section className="rounded-xl border border-rose-200 bg-rose-50 px-4 py-3 flex gap-3 text-sm text-rose-900">
<CircleAlert className="w-4 h-4 mt-0.5 shrink-0 text-rose-600" />
<p><strong>Import bloqué.</strong> Le chiffrement AES-256-GCM n'est pas disponible dans cet environnement. Aucun PDF n'est transmis sans chiffrement.</p>
</section>
)}
<section className="bg-card border border-border rounded-xl shadow-sm p-4">
<div className="flex flex-wrap items-end gap-3">
<div className="flex items-center gap-2 text-sm font-semibold text-foreground mr-2">
<CalendarDays className="w-4 h-4 text-emerald-600" /> Période affichée
</div>
<label className="grid gap-1 text-xs font-medium text-muted-foreground">
Année
<select value={annee} onChange={(event) => setAnnee(Number(event.target.value))} className="h-9 min-w-28 rounded-lg border border-border bg-background px-3 text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-emerald-300">
{years.map((year) => <option key={year} value={year}>{year}</option>)}
</select>
</label>
<label className="grid gap-1 text-xs font-medium text-muted-foreground">
Mois
<select value={mois} onChange={(event) => setMois(Number(event.target.value))} className="h-9 min-w-36 rounded-lg border border-border bg-background px-3 text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-emerald-300">
{MONTHS.map((month, index) => <option key={month} value={index + 1}>{month}</option>)}
</select>
</label>
<div className="flex-1" />
<span className="text-xs text-muted-foreground">Les montants proviennent uniquement de la liasse importée.</span>
</div>
</section>
<section className="bg-card border border-border rounded-xl shadow-sm overflow-hidden">
<div className="px-5 py-4 border-b border-border flex flex-wrap items-center justify-between gap-3">
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-lg bg-emerald-500/15 flex items-center justify-center"><Upload className="w-4 h-4 text-emerald-600" /></div>
<div><h2 className="font-semibold text-foreground">Archiver la liasse de {MONTHS[mois - 1]} {annee}</h2><p className="text-xs text-muted-foreground">Le PDF est chiffré avant envoi vers le stockage persistant hors conteneur.</p></div>
</div>
{hasExistingPeriod && <span className="rounded-full bg-amber-100 px-2.5 py-1 text-xs font-semibold text-amber-800">Période déjà archivée</span>}
</div>
<div className="p-5 flex flex-col lg:flex-row lg:items-center gap-4">
<div className="flex-1 min-h-20 rounded-xl border border-dashed border-emerald-300 bg-emerald-50/50 px-4 flex items-center gap-3">
<FileText className="w-6 h-6 text-emerald-600 shrink-0" />
<span className="min-w-0 flex-1"><strong className="block text-sm text-foreground truncate">{selectedFile?.name ?? "Choisir la liasse PDF"}</strong><span className="text-xs text-muted-foreground">PDF uniquement, 25 Mo maximum</span><input ref={inputRef} type="file" accept="application/pdf,.pdf" aria-label="Choisir la liasse PDF" onChange={(event) => setSelectedFile(event.target.files?.[0] ?? null)} className="mt-1.5 block max-w-full text-xs text-muted-foreground file:mr-3 file:rounded-md file:border-0 file:bg-emerald-600 file:px-2.5 file:py-1 file:text-xs file:font-semibold file:text-white hover:file:bg-emerald-700" /></span>
</div>
<button onClick={uploadLiasse} disabled={isUploading || hasExistingPeriod || !selectedFile || !security?.ready} className="h-10 inline-flex items-center justify-center gap-2 rounded-lg bg-emerald-600 px-4 text-sm font-semibold text-white hover:bg-emerald-700 disabled:cursor-not-allowed disabled:opacity-50 transition-colors active:scale-[0.97]">
{isUploading ? <Loader2 className="w-4 h-4 animate-spin" /> : <LockKeyhole className="w-4 h-4" />} {isUploading ? "Archivage…" : "Importer et chiffrer"}
</button>
</div>
</section>
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
<StatCard icon={Users} label="Salariés indexés" value={String(data?.indicateurs.nombreSalaries ?? 0)} accent="text-emerald-600" />
<StatCard icon={Banknote} label="Brut total du mois" value={formatEuros(data?.indicateurs.totalBrutCents)} accent="text-emerald-600" />
<StatCard icon={(data?.indicateurs.totalEcartCents ?? 0) < 0 ? TrendingDown : TrendingUp} label="Écart brut global M-1" value={formatEuros(data?.indicateurs.totalEcartCents)} accent={(data?.indicateurs.totalEcartCents ?? 0) < 0 ? "text-rose-600" : "text-emerald-600"} />
</div>
<section className="bg-card border border-border rounded-xl shadow-sm overflow-hidden">
<div className="px-5 py-4 border-b border-border flex items-center gap-3">
<div className="w-8 h-8 rounded-lg bg-emerald-500/15 flex items-center justify-center"><Users className="w-4 h-4 text-emerald-600" /></div>
<div><h2 className="font-semibold text-foreground">Salariés et évolution mensuelle</h2><p className="text-xs text-muted-foreground">Comparaison avec le même salarié de la liasse M-1 ; les variations sont explicitées à partir des rubriques extraites.</p></div>
</div>
{isLoading ? <div className="py-14 flex justify-center"><Loader2 className="w-5 h-5 text-emerald-600 animate-spin" /></div> : isError ? <DataNotice message="Les salaires de cette période ne peuvent pas être chargés." /> : data?.bulletins.length ? (
<div className="overflow-auto"><table className="w-full min-w-[1250px] text-sm"><thead className="bg-muted/80 text-[10px] uppercase tracking-wide text-muted-foreground"><tr><th className="text-left px-4 py-3">Salarié</th><th className="text-left px-4 py-3">Poste</th><th className="text-right px-4 py-3">Base mensuelle</th><th className="text-right px-4 py-3">Brut courant</th><th className="text-right px-4 py-3">Brut M-1</th><th className="text-right px-4 py-3">Écart</th><th className="text-left px-4 py-3">Explication</th><th className="text-center px-4 py-3">PDF</th></tr></thead><tbody>{data.bulletins.map((bulletin) => <tr key={`${bulletin.liasseId}-${bulletin.matricule}`} className="border-t border-border/70 hover:bg-muted/30"><td className="px-4 py-3"><p className="font-semibold text-foreground">{bulletin.nom} {bulletin.prenom}</p><p className="font-mono text-[11px] text-muted-foreground">{bulletin.matricule}</p></td><td className="px-4 py-3 text-xs text-foreground">{bulletin.poste}</td><td className="px-4 py-3 text-right whitespace-nowrap">{formatEuros(bulletin.brutMensuelCents)}</td><td className="px-4 py-3 text-right font-semibold whitespace-nowrap">{formatEuros(bulletin.brutAvecPrimesCents)}</td><td className="px-4 py-3 text-right whitespace-nowrap">{formatEuros(bulletin.brutPrecedentCents)}</td><td className={`px-4 py-3 text-right font-semibold whitespace-nowrap ${(bulletin.ecartBrutCents ?? 0) < 0 ? "text-rose-700" : (bulletin.ecartBrutCents ?? 0) > 0 ? "text-emerald-700" : "text-muted-foreground"}`}>{formatEuros(bulletin.ecartBrutCents)}<span className="block text-[11px] font-normal">{formatPercent(bulletin.ecartBrutPourcentage)}</span></td><td className="px-4 py-3 max-w-80 text-xs text-muted-foreground">{bulletin.explication}</td><td className="px-4 py-3 text-center"><a href={`/api/salaires/liasses/${bulletin.liasseId}/pdf#page=${bulletin.numeroPage ?? 1}`} target="_blank" rel="noopener noreferrer" className="inline-flex items-center gap-1 text-xs font-semibold text-emerald-700 hover:text-emerald-900"><FileText className="w-3.5 h-3.5" />Voir</a></td></tr>)}</tbody></table></div>
) : <DataNotice message={`Aucun bulletin n'est indexé pour ${MONTHS[mois - 1]} ${annee}.`} />}
</section>
<section className="bg-card border border-border rounded-xl shadow-sm overflow-hidden">
<div className="px-5 py-4 border-b border-border flex items-center gap-3"><div className="w-8 h-8 rounded-lg bg-emerald-500/15 flex items-center justify-center"><Archive className="w-4 h-4 text-emerald-600" /></div><div><h2 className="font-semibold text-foreground">Liasses archivées</h2><p className="text-xs text-muted-foreground">Chaque archive reste disponible après reconstruction ou changement de version de l'application.</p></div></div>
{periods.length === 0 ? <DataNotice message="Aucune liasse de bulletins n'a encore é archivée." /> : <div className="divide-y divide-border">{periods.map((period) => { const status = extractionStatusLabel(period.statutExtraction); return <div key={period.id} className="px-5 py-3 flex flex-wrap gap-3 items-center"><div className="min-w-40"><p className="font-semibold text-sm text-foreground">{MONTHS[period.mois - 1]} {period.annee}</p><p className="text-xs text-muted-foreground truncate max-w-72">{period.nomFichier}</p></div><span className={`rounded-full px-2.5 py-1 text-[11px] font-semibold ${status.className}`}>{status.label}</span><div className="flex-1 text-xs text-muted-foreground">{period.erreurExtraction ?? "Index complet."}</div>{period.statutExtraction !== "ready" && <button onClick={() => reindexLiasse(period.id)} disabled={reindexingId === period.id} className="inline-flex items-center gap-1 text-xs font-semibold text-amber-800 hover:text-amber-950 disabled:opacity-50"><Loader2 className={`w-3.5 h-3.5 ${reindexingId === period.id ? "animate-spin" : ""}`} />{reindexingId === period.id ? "Réindexation" : "Réindexer"}</button>}<a href={`/api/salaires/liasses/${period.id}/pdf`} target="_blank" rel="noopener noreferrer" className="inline-flex items-center gap-1 text-xs font-semibold text-emerald-700 hover:text-emerald-900"><FileText className="w-3.5 h-3.5" />Voir la liasse</a></div>; })}</div>}
</section>
</div>
</main>
</div>
);
}
function StatCard({ icon: Icon, label, value, accent }: { icon: React.ElementType; label: string; value: string; accent: string }) {
return <section className="bg-card border border-border rounded-xl shadow-sm p-4"><div className={`flex items-center gap-2 text-sm font-semibold ${accent}`}><Icon className="w-4 h-4" />{label}</div><p className="mt-3 text-2xl font-bold text-foreground">{value}</p></section>;
}
function DataNotice({ message }: { message: string }) {
return <div className="py-12 text-center text-sm text-muted-foreground"><CircleAlert className="w-5 h-5 mx-auto mb-2 text-muted-foreground/60" />{message}</div>;
}

25
docs/salaires-securite.md Normal file
View File

@@ -0,0 +1,25 @@
# Module Salaires — sécurité et conservation
Le module **Salaires** est réservé aux comptes de profil `admin`, à la fois dans la navigation, dans les routes clientes, dans les procédures métier et sur les deux routes binaires serveur. Les profils `standard` et `readonly` ne peuvent ni lister les données, ni importer, ni consulter une liasse.
Les fichiers PDF sont chiffrés côté serveur avec AES-256-GCM avant leur envoi vers le stockage persistant du projet. La base de données contient seulement les métadonnées indispensables, les paramètres cryptographiques par fichier et l'index métier minimal : matricule, identité professionnelle, poste et montants bruts. Elle ne contient ni adresse, ni IBAN, ni numéro de sécurité sociale, ni net à payer.
La consultation récupère le contenu chiffré exclusivement côté serveur, le déchiffre après contrôle de la session administrateur et retourne le PDF avec les entêtes `Cache-Control: no-store` et `X-Content-Type-Options: nosniff`. Le navigateur ne reçoit aucune URL de stockage ni paramètre de chiffrement.
La page a été vérifiée dans une session administrateur le 29 août 2026 : la navigation SANTINOVA affiche l'entrée Salaires, le statut de chiffrement est opérationnel, les contrôles année/mois, la zone d'import PDF, les indicateurs, l'état vide et les archives sont rendus sans erreur visible.
La validation bout en bout utilise une liasse mensuelle fournie. Elle portera sur l'archivage du fichier chiffré, l'index métier restreint, le filtrage par période, l'explication M-1 et la réouverture après redémarrage du serveur.
Lors du premier import réel de mai 2026, l'archive chiffrée a été créée et est restée consultable, mais l'index a été signalé « à contrôler ». Le diagnostic a confirmé que l'extracteur PDF compactait la mise en page : 22 en-têtes de bulletin ont été détectés, mais les matricules étaient présentés sous la forme « Matricule : M 000… » et l'espace avant la civilité était réduit. Le parseur a été corrigé à partir de ces seules caractéristiques de structure, puis une réindexation sans remplacement du PDF a été ajoutée.
Après correction, l'analyse de la liasse réelle a reconnu 18 bulletins sur 22, sans qu'aucun contenu d'adresse, d'IBAN ou de numéro de sécurité sociale ne soit détecté dans le poste. L'archive doit donc rester au statut `partiel` et signaler quatre fiches à contrôler, plutôt que d'inventer leurs montants.
La réindexation de l'archive de mai 2026 a ensuite été exécutée dans une session administrateur sans nouveau téléversement ni remplacement du PDF. Elle a persisté l'index partiel de 18 bulletins et le statut d'avertissement adéquat.
Le filtre sur mai 2026 a été vérifié dans l'interface administrateur : la liste affiche les 18 salariés indexés avec le poste, la base mensuelle, le brut courant, l'absence justifiée de référence M-1 et le lien vers la liasse contrôlée. Les quatre bulletins non indexés demeurent explicitement signalés par le statut partiel.
Une seconde liasse réelle, pour juin 2026, a été archivée et indexée au même statut partiel. Le filtre de juin affiche les 18 enregistrements indexés et compare chaque brut à celui de mai : les écarts et leurs explications reposent sur la variation des seules astreintes et des autres éléments inclus dans le brut. Les variations non reconnues restent explicitement signalées, sans valeur inventée.
La consultation réelle de la liasse de juin a retourné un document dont la signature est `%PDF-`, avec `Content-Type: application/pdf`, `Cache-Control: no-store, private` et `X-Content-Type-Options: nosniff`. Aucune URL de stockage n'a été renvoyée au navigateur.
Après un redémarrage complet du serveur de développement, les deux archives mensuelles et leurs statuts d'indexation étaient toujours présents dans l'interface. La relecture de la liasse de juin a de nouveau retourné un PDF valide, avec l'entête `no-store`. Cette vérification confirme que le fichier chiffré est lu depuis le stockage persistant extérieur au processus applicatif, et non depuis le système de fichiers d'un conteneur.

View File

@@ -0,0 +1,36 @@
CREATE TABLE `salaires_bulletins` (
`id` int AUTO_INCREMENT NOT NULL,
`liasseId` int NOT NULL,
`matricule` varchar(32) NOT NULL,
`nom` varchar(150) NOT NULL,
`prenom` varchar(150) NOT NULL,
`poste` varchar(255) NOT NULL,
`brutMensuelCents` int NOT NULL,
`brutAvecPrimesCents` int NOT NULL,
`primeAstreinteCents` int NOT NULL DEFAULT 0,
`explicationEcart` text,
`numeroPage` int,
`createdAt` timestamp NOT NULL DEFAULT (now()),
`updatedAt` timestamp NOT NULL DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT `salaires_bulletins_id` PRIMARY KEY(`id`),
CONSTRAINT `salaires_bulletins_liasse_matricule_idx` UNIQUE(`liasseId`,`matricule`)
);
--> statement-breakpoint
CREATE TABLE `salaires_liasses` (
`id` int AUTO_INCREMENT NOT NULL,
`annee` int NOT NULL,
`mois` int NOT NULL,
`nomFichier` varchar(255) NOT NULL,
`stockageKey` varchar(512) NOT NULL,
`empreinteSha256` varchar(64) NOT NULL,
`tailleOctets` int NOT NULL,
`ivBase64` varchar(32) NOT NULL,
`authTagBase64` varchar(32) NOT NULL,
`versionCle` int NOT NULL DEFAULT 1,
`importePar` int NOT NULL,
`importeLe` timestamp NOT NULL DEFAULT (now()),
CONSTRAINT `salaires_liasses_id` PRIMARY KEY(`id`),
CONSTRAINT `salaires_liasses_stockageKey_unique` UNIQUE(`stockageKey`),
CONSTRAINT `salaires_liasses_empreinteSha256_unique` UNIQUE(`empreinteSha256`),
CONSTRAINT `salaires_liasses_periode_empreinte_idx` UNIQUE(`annee`,`mois`,`empreinteSha256`)
);

View File

@@ -0,0 +1 @@
ALTER TABLE `salaires_liasses` ADD CONSTRAINT `salaires_liasses_periode_idx` UNIQUE(`annee`,`mois`);

View File

@@ -0,0 +1,2 @@
ALTER TABLE `salaires_liasses` ADD `statutExtraction` varchar(32) DEFAULT 'ready' NOT NULL;--> statement-breakpoint
ALTER TABLE `salaires_liasses` ADD `erreurExtraction` text;

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -57,6 +57,27 @@
"when": 1787833910943,
"tag": "0007_rich_tony_stark",
"breakpoints": true
},
{
"idx": 8,
"version": "5",
"when": 1788040817404,
"tag": "0008_living_silver_surfer",
"breakpoints": true
},
{
"idx": 9,
"version": "5",
"when": 1788041511204,
"tag": "0009_white_stardust",
"breakpoints": true
},
{
"idx": 10,
"version": "5",
"when": 1788041740554,
"tag": "0010_brainy_oracle",
"breakpoints": true
}
]
}

View File

@@ -265,3 +265,64 @@ export const masseSalarialeEvolutions = mysqlTable("masse_salariale_evolutions",
export type MasseSalarialeEvolution = typeof masseSalarialeEvolutions.$inferSelect;
export type InsertMasseSalarialeEvolution = typeof masseSalarialeEvolutions.$inferInsert;
// ─────────────────────────────────────────────────────────────────────────────
// SALAIRES MENSUELS — Liasses PDF chiffrées et données extraites
// ─────────────────────────────────────────────────────────────────────────────
/**
* Une liasse représente un dépôt mensuel. Le fichier est chiffré avant envoi
* vers le stockage persistant ; la base ne conserve ni octet de PDF ni clé.
*/
export const salairesLiasses = mysqlTable("salaires_liasses", {
id: int("id").autoincrement().primaryKey(),
annee: int("annee").notNull(),
mois: int("mois").notNull(),
nomFichier: varchar("nomFichier", { length: 255 }).notNull(),
stockageKey: varchar("stockageKey", { length: 512 }).notNull().unique(),
empreinteSha256: varchar("empreinteSha256", { length: 64 }).notNull().unique(),
tailleOctets: int("tailleOctets").notNull(),
/** Paramètres AES-256-GCM nécessaires au déchiffrement côté serveur. */
ivBase64: varchar("ivBase64", { length: 32 }).notNull(),
authTagBase64: varchar("authTagBase64", { length: 32 }).notNull(),
versionCle: int("versionCle").notNull().default(1),
/** `ready`, `partiel` ou `a_controler` : une liasse est conservée même si l'extraction est incomplète. */
statutExtraction: varchar("statutExtraction", { length: 32 }).notNull().default("ready"),
erreurExtraction: text("erreurExtraction"),
importePar: int("importePar").notNull(),
importeLe: timestamp("importeLe").defaultNow().notNull(),
}, (t) => ({
/** Une liasse unique par mois : toutes les fiches mensuelles restent ainsi cohérentes. */
uniqPeriode: uniqueIndex("salaires_liasses_periode_idx").on(t.annee, t.mois),
/** Empêche de traiter deux fois le même fichier sous une période distincte. */
uniqPeriodeEmpreinte: uniqueIndex("salaires_liasses_periode_empreinte_idx").on(t.annee, t.mois, t.empreinteSha256),
}));
export type SalaireLiasse = typeof salairesLiasses.$inferSelect;
export type InsertSalaireLiasse = typeof salairesLiasses.$inferInsert;
/**
* Données utiles par bulletin. `explicationEcart` conserve uniquement les
* rubriques de rémunération nécessaires à expliquer une variation mensuelle.
*/
export const salairesBulletins = mysqlTable("salaires_bulletins", {
id: int("id").autoincrement().primaryKey(),
liasseId: int("liasseId").notNull(),
matricule: varchar("matricule", { length: 32 }).notNull(),
nom: varchar("nom", { length: 150 }).notNull(),
prenom: varchar("prenom", { length: 150 }).notNull(),
poste: varchar("poste", { length: 255 }).notNull(),
brutMensuelCents: int("brutMensuelCents").notNull(),
brutAvecPrimesCents: int("brutAvecPrimesCents").notNull(),
primeAstreinteCents: int("primeAstreinteCents").notNull().default(0),
explicationEcart: text("explicationEcart"),
numeroPage: int("numeroPage"),
createdAt: timestamp("createdAt").defaultNow().notNull(),
updatedAt: timestamp("updatedAt").defaultNow().onUpdateNow().notNull(),
}, (t) => ({
/** Un seul bulletin indexé par matricule dans une liasse donnée. */
uniqLiasseMatricule: uniqueIndex("salaires_bulletins_liasse_matricule_idx").on(t.liasseId, t.matricule),
}));
export type SalaireBulletin = typeof salairesBulletins.$inferSelect;
export type InsertSalaireBulletin = typeof salairesBulletins.$inferInsert;

View File

@@ -62,9 +62,11 @@
"input-otp": "^1.4.2",
"jose": "6.1.0",
"lucide-react": "^0.453.0",
"multer": "2.0.2",
"mysql2": "^3.15.0",
"nanoid": "^5.1.5",
"next-themes": "^0.4.6",
"pdf-parse": "1.1.1",
"react": "^19.2.1",
"react-day-picker": "^9.11.1",
"react-dom": "^19.2.1",
@@ -88,9 +90,11 @@
"@types/bcryptjs": "^3.0.0",
"@types/express": "4.17.21",
"@types/google.maps": "^3.58.1",
"@types/multer": "2.0.0",
"@types/node": "^24.7.0",
"@types/react": "^19.2.1",
"@types/react-dom": "^19.2.1",
"@types/supertest": "6.0.3",
"@vitejs/plugin-react": "^5.0.4",
"add": "^2.0.6",
"autoprefixer": "^10.4.20",
@@ -99,6 +103,7 @@
"pnpm": "^10.15.1",
"postcss": "^8.4.47",
"prettier": "^3.6.2",
"supertest": "7.1.4",
"tailwindcss": "^4.1.14",
"tsx": "^4.19.1",
"tw-animate-css": "^1.4.0",

312
pnpm-lock.yaml generated
View File

@@ -160,6 +160,9 @@ importers:
lucide-react:
specifier: ^0.453.0
version: 0.453.0(react@19.2.1)
multer:
specifier: 2.0.2
version: 2.0.2
mysql2:
specifier: ^3.15.0
version: 3.22.5(@types/node@24.7.0)
@@ -169,6 +172,9 @@ importers:
next-themes:
specifier: ^0.4.6
version: 0.4.6(react-dom@19.2.1(react@19.2.1))(react@19.2.1)
pdf-parse:
specifier: 1.1.1
version: 1.1.1
react:
specifier: ^19.2.1
version: 19.2.1
@@ -233,6 +239,9 @@ importers:
'@types/google.maps':
specifier: ^3.58.1
version: 3.58.1
'@types/multer':
specifier: 2.0.0
version: 2.0.0
'@types/node':
specifier: ^24.7.0
version: 24.7.0
@@ -242,6 +251,9 @@ importers:
'@types/react-dom':
specifier: ^19.2.1
version: 19.2.1(@types/react@19.2.1)
'@types/supertest':
specifier: 6.0.3
version: 6.0.3
'@vitejs/plugin-react':
specifier: ^5.0.4
version: 5.0.4(vite@7.1.9(@types/node@24.7.0)(jiti@2.6.1)(lightningcss@1.30.1)(tsx@4.20.6))
@@ -266,6 +278,9 @@ importers:
prettier:
specifier: ^3.6.2
version: 3.6.2
supertest:
specifier: 7.1.4
version: 7.1.4
tailwindcss:
specifier: ^4.1.14
version: 4.1.14
@@ -1164,9 +1179,16 @@ packages:
'@mermaid-js/parser@0.6.3':
resolution: {integrity: sha512-lnjOhe7zyHjc+If7yT4zoedx2vo4sHaTmtkl1+or8BRTnCtDmcTpAjpzDSfCZrshM5bCoz0GyidzadJAH1xobA==}
'@noble/hashes@1.8.0':
resolution: {integrity: sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==}
engines: {node: ^14.21.3 || >=16}
'@nodable/entities@2.1.1':
resolution: {integrity: sha512-Pig3HxDIoMgjdEH8OCf/dkcTmLFjJRjWuq8jSnklu284/TKOPibSRERmOykiwmyXTtv61mP+44f3GMx0tLAyjg==}
'@paralleldrive/cuid2@2.3.1':
resolution: {integrity: sha512-XO7cAxhnTZl0Yggq6jOgjiOHhbgcO4NqFqwSmQpjK3b6TEE6Uj/jfSk6wzYyemh3+I0sHirKSetjQwn5cZktFw==}
'@radix-ui/number@1.1.1':
resolution: {integrity: sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g==}
@@ -2099,6 +2121,9 @@ packages:
'@types/connect@3.4.38':
resolution: {integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==}
'@types/cookiejar@2.1.5':
resolution: {integrity: sha512-he+DHOWReW0nghN24E1WUqM0efK4kI9oTqDm6XmK8ZPe2djZ90BSNdGnIyCLzCPw7/pogPlGbzI2wHGGmi4O/Q==}
'@types/d3-array@3.2.2':
resolution: {integrity: sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==}
@@ -2225,12 +2250,18 @@ packages:
'@types/mdast@4.0.4':
resolution: {integrity: sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==}
'@types/methods@1.1.4':
resolution: {integrity: sha512-ymXWVrDiCxTBE3+RIrrP533E70eA+9qu7zdWoHuOmGujkYtzf4HQF96b8nwHLqhuf4ykX61IGRIB38CC6/sImQ==}
'@types/mime@1.3.5':
resolution: {integrity: sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==}
'@types/ms@2.1.0':
resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==}
'@types/multer@2.0.0':
resolution: {integrity: sha512-C3Z9v9Evij2yST3RSBktxP9STm6OdMc5uR1xF1SGr98uv8dUlAL2hqwrZ3GVB3uyMyiegnscEK6PGtYvNrjTjw==}
'@types/node@24.7.0':
resolution: {integrity: sha512-IbKooQVqUBrlzWTi79E8Fw78l8k1RNtlDDNWsFZs7XonuQSJ8oNYfEeclhprUldXISRMLzBpILuKgPlIxm+/Yw==}
@@ -2257,6 +2288,12 @@ packages:
'@types/serve-static@1.15.9':
resolution: {integrity: sha512-dOTIuqpWLyl3BBXU3maNQsS4A3zuuoYRNIvYSxxhebPfXg2mzWQEPne/nlJ37yOse6uGgR386uTpdsx4D0QZWA==}
'@types/superagent@8.1.11':
resolution: {integrity: sha512-KA7srSW/HENDtOw9DOqaFLgWuMqN9WgjEw62lh9dpvRaZDkhdOkazASd7X7i2eMUYLHa1U37ZttnePsH5zTDHw==}
'@types/supertest@6.0.3':
resolution: {integrity: sha512-8WzXq62EXFhJ7QsH3Ocb/iKQ/Ty9ZVWnVzoTKc9tyyFRRF3a74Tk2+TLFgaFFw364Ere+npzHKEJ6ga2LzIL7w==}
'@types/trusted-types@2.0.7':
resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==}
@@ -2323,6 +2360,9 @@ packages:
anynum@1.0.0:
resolution: {integrity: sha512-xjR9/zBVnUOP6ztMIIgShjsxui80nQUQH+5xJnvrYLs+90bF25/KJqaAi8mk+B4RDtX1Nspi6fmp4YTEts8SfA==}
append-field@1.0.0:
resolution: {integrity: sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==}
aria-hidden@1.2.6:
resolution: {integrity: sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==}
engines: {node: '>=10'}
@@ -2330,6 +2370,9 @@ packages:
array-flatten@1.1.1:
resolution: {integrity: sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==}
asap@2.0.6:
resolution: {integrity: sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==}
assertion-error@2.0.1:
resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==}
engines: {node: '>=12'}
@@ -2377,6 +2420,10 @@ packages:
buffer-from@1.1.2:
resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==}
busboy@1.6.0:
resolution: {integrity: sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==}
engines: {node: '>=10.16.0'}
bytes@3.1.2:
resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==}
engines: {node: '>= 0.8'}
@@ -2467,6 +2514,13 @@ packages:
resolution: {integrity: sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==}
engines: {node: '>= 12'}
component-emitter@1.3.1:
resolution: {integrity: sha512-T0+barUSQRTUQASh8bx02dl+DhF54GtIDY13Y3m9oWTklKbb3Wv974meRpeZ3lp1JpLVECWWNHC4vaG2XHXouQ==}
concat-stream@2.0.0:
resolution: {integrity: sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==}
engines: {'0': node >= 6.0}
confbox@0.1.8:
resolution: {integrity: sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==}
@@ -2495,6 +2549,9 @@ packages:
resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==}
engines: {node: '>=18'}
cookiejar@2.1.4:
resolution: {integrity: sha512-LDx6oHrK+PhzLKJU9j5S7/Y3jM/mUHvD/DeI1WQmJn652iPC5Y4TBzC9l+5OMOXlyTTA+SmVUPm0HQUwpD5Jqw==}
copy-anything@3.0.5:
resolution: {integrity: sha512-yCEafptTtb4bk7GLEQoM8KVJpxAfdBJYaXyzQEgQQQgYrZiDp8SJmGKlYza6CYjEDNstAdNdKA3UuoULlEbS6w==}
engines: {node: '>=12.13'}
@@ -2691,6 +2748,14 @@ packages:
supports-color:
optional: true
debug@3.2.7:
resolution: {integrity: sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==}
peerDependencies:
supports-color: '*'
peerDependenciesMeta:
supports-color:
optional: true
debug@4.4.3:
resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==}
engines: {node: '>=6.0'}
@@ -2743,6 +2808,9 @@ packages:
devlop@1.1.0:
resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==}
dezalgo@1.0.4:
resolution: {integrity: sha512-rXSP0bf+5n0Qonsb+SVVfNfIsimO4HEtmnIpPHY8Q1UCzKlQrDMfdobr8nJOOsRgWCyMRqeSBQzmWUMq7zvVig==}
dom-helpers@5.2.1:
resolution: {integrity: sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==}
@@ -2972,6 +3040,9 @@ packages:
resolution: {integrity: sha512-6rxyATwPCkaFIL3JLqw8qXqMpIZ942pTX/tbQFkRsDGblS8tNGtlUauA/+mt6RUfqn/4MoEr+WDkYoIQbibWuQ==}
engines: {node: '>=6.0.0'}
fast-safe-stringify@2.1.1:
resolution: {integrity: sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==}
fast-xml-builder@1.2.0:
resolution: {integrity: sha512-00aAWieqff+ZJhsXA4g1g7M8k+7AYoMUUHF+/zFb5U6Uv/P0Vl4QZo84/IcufzYalLuEj9928bXN9PbbFzMF0Q==}
@@ -3005,6 +3076,14 @@ packages:
resolution: {integrity: sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==}
engines: {node: '>= 6'}
form-data@4.0.6:
resolution: {integrity: sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==}
engines: {node: '>= 6'}
formidable@3.5.4:
resolution: {integrity: sha512-YikH+7CUTOtP44ZTnUhR7Ic2UASBPOqmaRkRKxRbywPTe5VxF7RRCck4af9wutiZ/QKM5nME9Bie2fFaPz5Gug==}
engines: {node: '>=14.0.0'}
forwarded@0.2.0:
resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==}
engines: {node: '>= 0.6'}
@@ -3090,6 +3169,10 @@ packages:
resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==}
engines: {node: '>= 0.4'}
hasown@2.0.4:
resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==}
engines: {node: '>= 0.4'}
hast-util-from-dom@5.0.1:
resolution: {integrity: sha512-N+LqofjR2zuzTjCPzyDUdSshy4Ma6li7p/c3pA78uTwzFgENbgbUrm2ugwsOdcjI1muO+o6Dgzp9p8WHtn/39Q==}
@@ -3518,6 +3601,14 @@ packages:
engines: {node: '>=4'}
hasBin: true
mime@2.6.0:
resolution: {integrity: sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==}
engines: {node: '>=4.0.0'}
hasBin: true
minimist@1.2.8:
resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==}
minipass@7.1.2:
resolution: {integrity: sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==}
engines: {node: '>=16 || 14 >=14.17'}
@@ -3529,6 +3620,10 @@ packages:
mitt@3.0.1:
resolution: {integrity: sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==}
mkdirp@0.5.6:
resolution: {integrity: sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==}
hasBin: true
mlly@1.8.0:
resolution: {integrity: sha512-l8D9ODSRWLe2KHJSifWGwBqpTZXIXTeo8mlKjY+E2HAakaTeNpqAyBZ8GSqLzHgw4XmHmC8whvpjJNMbFZN7/g==}
@@ -3547,6 +3642,10 @@ packages:
ms@2.1.3:
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
multer@2.0.2:
resolution: {integrity: sha512-u7f2xaZ/UG8oLXHvtF/oWTRvT44p9ecwBBqTwgJVq0+4BW1g8OW01TyMEGWBHbyMOYVHXslaut7qEQ1meATXgw==}
engines: {node: '>= 10.16.0'}
mysql2@3.22.5:
resolution: {integrity: sha512-95uZ2TrPWAZdwpB3vvvDbmEMcNG8yIeNCyu6GUcr/QnWEE/wXm7+mhOCsdQfWQDTV7qYT/PDUZ4U4UPP4AsXqQ==}
engines: {node: '>= 8.0'}
@@ -3577,6 +3676,9 @@ packages:
react: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc
react-dom: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc
node-ensure@0.0.0:
resolution: {integrity: sha512-DRI60hzo2oKN1ma0ckc6nQWlHU69RH6xN0sjQTjMpChPfTYvKZdcQFfdYK2RWbJcKyUizSIy/l8OTGxMAM1QDw==}
node-releases@2.0.23:
resolution: {integrity: sha512-cCmFDMSm26S6tQSDpBCg/NR8NENrVPhAJSf+XbxBG4rPFaaonlEoE9wHQmun+cls499TQGSb7ZyPBRlzgKfpeg==}
@@ -3596,6 +3698,9 @@ packages:
resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==}
engines: {node: '>= 0.8'}
once@1.4.0:
resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==}
oniguruma-parser@0.12.1:
resolution: {integrity: sha512-8Unqkvk1RYc6yq2WBYRj4hdnsAxVze8i7iPfQr8e4uSP3tRv0rpZcbGUDvxfQQcdwHt/e9PrMvGCsa8OqG9X3w==}
@@ -3635,6 +3740,10 @@ packages:
resolution: {integrity: sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==}
engines: {node: '>= 14.16'}
pdf-parse@1.1.1:
resolution: {integrity: sha512-v6ZJ/efsBpGrGGknjtq9J/oC8tZWq0KWL5vQrk2GlzLEQPUDB1ex+13Rmidl1neNN358Jn9EHZw5y07FFtaC7A==}
engines: {node: '>=6.8.1'}
picocolors@1.1.1:
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
@@ -3695,6 +3804,10 @@ packages:
resolution: {integrity: sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg==}
engines: {node: '>=0.6'}
qs@6.15.3:
resolution: {integrity: sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==}
engines: {node: '>=0.6'}
quansync@0.2.11:
resolution: {integrity: sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA==}
@@ -3791,6 +3904,10 @@ packages:
resolution: {integrity: sha512-DGrYcCWK7tvYMnWh79yrPHt+vdx9tY+1gPZa7nJQtO/p8bLTDaHp4dzwEhQB7pZ4Xe3ok4XKuEPrVuc+wlpkmw==}
engines: {node: '>=0.10.0'}
readable-stream@3.6.2:
resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==}
engines: {node: '>= 6'}
recharts-scale@0.4.5:
resolution: {integrity: sha512-kivNFO+0OcUNu7jQquLXAxz1FIwZj8nrj+YkOKc5694NbjCvcT6aSZiIzNzd2Kul4o4rTto8QVR9lMNtxD4G1w==}
@@ -3886,6 +4003,10 @@ packages:
resolution: {integrity: sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==}
engines: {node: '>= 0.4'}
side-channel-list@1.0.1:
resolution: {integrity: sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==}
engines: {node: '>= 0.4'}
side-channel-map@1.0.1:
resolution: {integrity: sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==}
engines: {node: '>= 0.4'}
@@ -3898,6 +4019,10 @@ packages:
resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==}
engines: {node: '>= 0.4'}
side-channel@1.1.1:
resolution: {integrity: sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==}
engines: {node: '>= 0.4'}
siginfo@2.0.0:
resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==}
@@ -3944,6 +4069,13 @@ packages:
peerDependencies:
react: ^18.0.0 || ^19.0.0
streamsearch@1.1.0:
resolution: {integrity: sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==}
engines: {node: '>=10.0.0'}
string_decoder@1.3.0:
resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==}
stringify-entities@4.0.4:
resolution: {integrity: sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==}
@@ -3959,10 +4091,18 @@ packages:
stylis@4.3.6:
resolution: {integrity: sha512-yQ3rwFWRfwNUY7H5vpU0wfdkNSnvnJinhF9830Swlaxl03zsOjCfmX0ugac+3LtK0lYSgwL/KXc8oYL3mG4YFQ==}
superagent@10.3.0:
resolution: {integrity: sha512-B+4Ik7ROgVKrQsXTV0Jwp2u+PXYLSlqtDAhYnkkD+zn3yg8s/zjA2MeGayPoY/KICrbitwneDHrjSotxKL+0XQ==}
engines: {node: '>=14.18.0'}
superjson@1.13.3:
resolution: {integrity: sha512-mJiVjfd2vokfDxsQPOwJ/PtanO87LhpYY88ubI5dUB1Ab58Txbyje3+jpm+/83R/fevaq/107NNhtYBLuoTrFg==}
engines: {node: '>=10'}
supertest@7.1.4:
resolution: {integrity: sha512-tjLPs7dVyqgItVFirHYqe2T+MfWc2VOBQ8QFKKbWTA3PU7liZR8zoSpAi/C1k1ilm9RsXIKYf197oap9wXGVYg==}
engines: {node: '>=14.18.0'}
tailwind-merge@3.3.1:
resolution: {integrity: sha512-gBXpgUm/3rp1lMZZrM/w7D8GKqshif0zAymAhbCyIt8KMe+0v9DQ7cdYLR4FHH/cKpdTXb+A/tKKU3eolfsI+g==}
@@ -4044,6 +4184,9 @@ packages:
resolution: {integrity: sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==}
engines: {node: '>= 0.6'}
typedarray@0.0.6:
resolution: {integrity: sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==}
typescript@5.9.3:
resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==}
engines: {node: '>=14.17'}
@@ -4292,6 +4435,9 @@ packages:
peerDependencies:
react: '>=16.8.0'
wrappy@1.0.2:
resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==}
xlsx@0.18.5:
resolution: {integrity: sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==}
engines: {node: '>=0.8'}
@@ -4301,6 +4447,10 @@ packages:
resolution: {integrity: sha512-k8KO9hrMyNk6tUWqUfkTEZbezRRpONVOzUTnc97VnCvyj6Tf9lyUR9EDAIeiVLv56jsMcoXEwjW8Kv5yPY52lw==}
engines: {node: '>=16.0.0'}
xtend@4.0.2:
resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==}
engines: {node: '>=0.4'}
yallist@3.1.1:
resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==}
@@ -5077,8 +5227,14 @@ snapshots:
dependencies:
langium: 3.3.1
'@noble/hashes@1.8.0': {}
'@nodable/entities@2.1.1': {}
'@paralleldrive/cuid2@2.3.1':
dependencies:
'@noble/hashes': 1.8.0
'@radix-ui/number@1.1.1': {}
'@radix-ui/primitive@1.1.3': {}
@@ -6017,6 +6173,8 @@ snapshots:
dependencies:
'@types/node': 24.7.0
'@types/cookiejar@2.1.5': {}
'@types/d3-array@3.2.2': {}
'@types/d3-axis@3.0.6':
@@ -6174,10 +6332,16 @@ snapshots:
dependencies:
'@types/unist': 3.0.3
'@types/methods@1.1.4': {}
'@types/mime@1.3.5': {}
'@types/ms@2.1.0': {}
'@types/multer@2.0.0':
dependencies:
'@types/express': 4.17.21
'@types/node@24.7.0':
dependencies:
undici-types: 7.14.0
@@ -6209,6 +6373,18 @@ snapshots:
'@types/node': 24.7.0
'@types/send': 0.17.5
'@types/superagent@8.1.11':
dependencies:
'@types/cookiejar': 2.1.5
'@types/methods': 1.1.4
'@types/node': 24.7.0
form-data: 4.0.4
'@types/supertest@6.0.3':
dependencies:
'@types/methods': 1.1.4
'@types/superagent': 8.1.11
'@types/trusted-types@2.0.7':
optional: true
@@ -6283,12 +6459,16 @@ snapshots:
anynum@1.0.0: {}
append-field@1.0.0: {}
aria-hidden@1.2.6:
dependencies:
tslib: 2.8.1
array-flatten@1.1.1: {}
asap@2.0.6: {}
assertion-error@2.0.1: {}
asynckit@0.4.0: {}
@@ -6348,6 +6528,10 @@ snapshots:
buffer-from@1.1.2: {}
busboy@1.6.0:
dependencies:
streamsearch: 1.1.0
bytes@3.1.2: {}
cac@6.7.14: {}
@@ -6435,6 +6619,15 @@ snapshots:
commander@8.3.0: {}
component-emitter@1.3.1: {}
concat-stream@2.0.0:
dependencies:
buffer-from: 1.1.2
inherits: 2.0.4
readable-stream: 3.6.2
typedarray: 0.0.6
confbox@0.1.8: {}
confbox@0.2.2: {}
@@ -6453,6 +6646,8 @@ snapshots:
cookie@1.1.1: {}
cookiejar@2.1.4: {}
copy-anything@3.0.5:
dependencies:
is-what: 4.1.16
@@ -6665,6 +6860,10 @@ snapshots:
dependencies:
ms: 2.0.0
debug@3.2.7:
dependencies:
ms: 2.1.3
debug@4.4.3:
dependencies:
ms: 2.1.3
@@ -6699,6 +6898,11 @@ snapshots:
dependencies:
dequal: 2.0.3
dezalgo@1.0.4:
dependencies:
asap: 2.0.6
wrappy: 1.0.2
dom-helpers@5.2.1:
dependencies:
'@babel/runtime': 7.28.4
@@ -6942,6 +7146,8 @@ snapshots:
fast-equals@5.3.2: {}
fast-safe-stringify@2.1.1: {}
fast-xml-builder@1.2.0:
dependencies:
path-expression-matcher: 1.5.0
@@ -6980,6 +7186,20 @@ snapshots:
hasown: 2.0.2
mime-types: 2.1.35
form-data@4.0.6:
dependencies:
asynckit: 0.4.0
combined-stream: 1.0.8
es-set-tostringtag: 2.1.0
hasown: 2.0.4
mime-types: 2.1.35
formidable@3.5.4:
dependencies:
'@paralleldrive/cuid2': 2.3.1
dezalgo: 1.0.4
once: 1.4.0
forwarded@0.2.0: {}
frac@1.1.2: {}
@@ -7050,6 +7270,10 @@ snapshots:
dependencies:
function-bind: 1.1.2
hasown@2.0.4:
dependencies:
function-bind: 1.1.2
hast-util-from-dom@5.0.1:
dependencies:
'@types/hast': 3.0.4
@@ -7750,6 +7974,10 @@ snapshots:
mime@1.6.0: {}
mime@2.6.0: {}
minimist@1.2.8: {}
minipass@7.1.2: {}
minizlib@3.1.0:
@@ -7758,6 +7986,10 @@ snapshots:
mitt@3.0.1: {}
mkdirp@0.5.6:
dependencies:
minimist: 1.2.8
mlly@1.8.0:
dependencies:
acorn: 8.15.0
@@ -7777,6 +8009,16 @@ snapshots:
ms@2.1.3: {}
multer@2.0.2:
dependencies:
append-field: 1.0.0
busboy: 1.6.0
concat-stream: 2.0.0
mkdirp: 0.5.6
object-assign: 4.1.1
type-is: 1.6.18
xtend: 4.0.2
mysql2@3.22.5(@types/node@24.7.0):
dependencies:
'@types/node': 24.7.0
@@ -7804,6 +8046,8 @@ snapshots:
react: 19.2.1
react-dom: 19.2.1(react@19.2.1)
node-ensure@0.0.0: {}
node-releases@2.0.23: {}
normalize-range@0.1.2: {}
@@ -7816,6 +8060,10 @@ snapshots:
dependencies:
ee-first: 1.1.1
once@1.4.0:
dependencies:
wrappy: 1.0.2
oniguruma-parser@0.12.1: {}
oniguruma-to-es@4.3.3:
@@ -7854,6 +8102,13 @@ snapshots:
pathval@2.0.1: {}
pdf-parse@1.1.1:
dependencies:
debug: 3.2.7
node-ensure: 0.0.0
transitivePeerDependencies:
- supports-color
picocolors@1.1.1: {}
picomatch@4.0.3: {}
@@ -7915,6 +8170,11 @@ snapshots:
dependencies:
side-channel: 1.1.0
qs@6.15.3:
dependencies:
es-define-property: 1.0.1
side-channel: 1.1.1
quansync@0.2.11: {}
range-parser@1.2.1: {}
@@ -8017,6 +8277,12 @@ snapshots:
react@19.2.1: {}
readable-stream@3.6.2:
dependencies:
inherits: 2.0.4
string_decoder: 1.3.0
util-deprecate: 1.0.2
recharts-scale@0.4.5:
dependencies:
decimal.js-light: 2.5.1
@@ -8201,6 +8467,11 @@ snapshots:
es-errors: 1.3.0
object-inspect: 1.13.4
side-channel-list@1.0.1:
dependencies:
es-errors: 1.3.0
object-inspect: 1.13.4
side-channel-map@1.0.1:
dependencies:
call-bound: 1.0.4
@@ -8224,6 +8495,14 @@ snapshots:
side-channel-map: 1.0.1
side-channel-weakmap: 1.0.2
side-channel@1.1.1:
dependencies:
es-errors: 1.3.0
object-inspect: 1.13.4
side-channel-list: 1.0.1
side-channel-map: 1.0.1
side-channel-weakmap: 1.0.2
siginfo@2.0.0: {}
sonner@2.0.7(react-dom@19.2.1(react@19.2.1))(react@19.2.1):
@@ -8274,6 +8553,12 @@ snapshots:
- '@types/react'
- supports-color
streamsearch@1.1.0: {}
string_decoder@1.3.0:
dependencies:
safe-buffer: 5.2.1
stringify-entities@4.0.4:
dependencies:
character-entities-html4: 2.1.0
@@ -8293,10 +8578,31 @@ snapshots:
stylis@4.3.6: {}
superagent@10.3.0:
dependencies:
component-emitter: 1.3.1
cookiejar: 2.1.4
debug: 4.4.3
fast-safe-stringify: 2.1.1
form-data: 4.0.6
formidable: 3.5.4
methods: 1.1.2
mime: 2.6.0
qs: 6.15.3
transitivePeerDependencies:
- supports-color
superjson@1.13.3:
dependencies:
copy-anything: 3.0.5
supertest@7.1.4:
dependencies:
methods: 1.1.2
superagent: 10.3.0
transitivePeerDependencies:
- supports-color
tailwind-merge@3.3.1: {}
tailwindcss-animate@1.0.7(tailwindcss@4.1.14):
@@ -8364,6 +8670,8 @@ snapshots:
media-typer: 0.3.0
mime-types: 2.1.35
typedarray@0.0.6: {}
typescript@5.9.3: {}
ufo@1.6.1: {}
@@ -8612,6 +8920,8 @@ snapshots:
regexparam: 3.0.0
use-sync-external-store: 1.6.0(react@19.2.1)
wrappy@1.0.2: {}
xlsx@0.18.5:
dependencies:
adler-32: 1.3.1
@@ -8624,6 +8934,8 @@ snapshots:
xml-naming@0.1.0: {}
xtend@4.0.2: {}
yallist@3.1.1: {}
yallist@5.0.0: {}

View File

@@ -0,0 +1,28 @@
import fs from "node:fs";
import { extractPayrollBulletins } from "../server/payrollParser";
import { extractPayrollPdfText } from "../server/payrollPdfText";
const filePath = process.argv[2];
if (!filePath) throw new Error("Chemin PDF requis");
const text = await extractPayrollPdfText(fs.readFileSync(filePath));
const extraction = extractPayrollBulletins(text);
const lines = text.split(/\r?\n/);
const redactStructure = (line: string) => line.replace(/[A-Za-zÀ-ÿ]/g, "x").replace(/\d/g, "#").slice(0, 240);
const prohibitedPosteContent = extraction.bulletins.some((bulletin) => /iban|rib|n°\s*ss|adresse|fr\d{2}/i.test(bulletin.poste));
console.log(JSON.stringify({
bulletins: extraction.bulletins.length,
statutExtraction: extraction.statutExtraction,
hasExtractionWarning: Boolean(extraction.erreurExtraction),
containsProhibitedPosteContent: prohibitedPosteContent,
preservedPageSeparators: (text.match(/\f/g) ?? []).length,
bulletinMarkers: (text.match(/##BULLETIN##/g) ?? []).length,
matriculeMatchCount: (text.match(/Matricule\s*:\s*(?:M|N|N°)?\s*\d+/g) ?? []).length,
emploiSameLineCount: lines.filter((line) => /Emploi\s*:.*(?:Monsieur|Madame)\b/i.test(line)).length,
baseLineCount: lines.filter((line) => line.trim().startsWith("Salaire de base")).length,
grossLineCount: lines.filter((line) => line.trim().startsWith("Salaire brut")).length,
emploiVicinityStructures: lines
.flatMap((line, index) => /Emploi\s*:/i.test(line) ? lines.slice(index, index + 4) : [])
.slice(0, 8)
.map(redactStructure),
}));

View File

@@ -6,6 +6,7 @@ import { createExpressMiddleware } from "@trpc/server/adapters/express";
import { registerOAuthRoutes } from "./oauth";
import { registerStorageProxy } from "./storageProxy";
import { appRouter } from "../routers";
import { registerSalairesHttpRoutes } from "../salairesHttp";
import { createContext } from "./context";
import { serveStatic, setupVite } from "./vite";
@@ -36,6 +37,7 @@ async function startServer() {
app.use(express.urlencoded({ limit: "50mb", extended: true }));
registerStorageProxy(app);
registerOAuthRoutes(app);
registerSalairesHttpRoutes(app);
// tRPC API
app.use(
"/api/trpc",

View File

@@ -74,6 +74,7 @@ vi.mock("./db", async (importOriginal) => {
getMasseSalariale: vi.fn(),
importMasseSalariale: vi.fn(),
setMasseSalarialeEvolution: vi.fn(),
getSalairesMensuels: vi.fn(),
};
});
@@ -434,3 +435,72 @@ describe("masseSalariale", () => {
expect(db.setMasseSalarialeEvolution).not.toHaveBeenCalled();
});
});
// ─── Tests Salaires : sonde de chiffrement et confidentialité ─────────────────
describe("salaires.getSecurityStatus", () => {
it("valide la clé de chiffrement configurée via une procédure administrateur", async () => {
const { ctx } = createAuthCtx({ role: "admin" });
await expect(appRouter.createCaller(ctx).salaires.getSecurityStatus())
.resolves.toEqual({ ready: true, algorithm: "AES-256-GCM" });
});
it("interdit la sonde de sécurité aux profils non administrateurs", async () => {
const { ctx } = createAuthCtx({ role: "standard" });
await expect(appRouter.createCaller(ctx).salaires.getSecurityStatus()).rejects.toThrow();
});
});
describe("salaires.list", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("retourne l'index métier et explique la variation uniquement à l'administrateur", async () => {
const { db } = await import("./db").then(m => ({ db: m }));
const mayLiasse = {
id: 9, annee: 2026, mois: 5, nomFichier: "mai.pdf", stockageKey: "secret-key",
empreinteSha256: "a".repeat(64), tailleOctets: 1200, ivBase64: "iv", authTagBase64: "tag",
versionCle: 1, statutExtraction: "ready", erreurExtraction: null, importePar: 1, importeLe: new Date(),
};
const juneLiasse = { ...mayLiasse, id: 10, mois: 6, nomFichier: "juin.pdf", stockageKey: "secret-key-2" };
const mayBulletin = {
id: 1, liasseId: 9, matricule: "000001", nom: "DUPONT", prenom: "TEST", poste: "Responsable",
brutMensuelCents: 300000, brutAvecPrimesCents: 320000, primeAstreinteCents: 20000,
explicationEcart: null, numeroPage: 1, createdAt: new Date(), updatedAt: new Date(),
};
const juneBulletin = { ...mayBulletin, id: 2, liasseId: 10, brutAvecPrimesCents: 350000, primeAstreinteCents: 40000 };
(db.getSalairesMensuels as ReturnType<typeof vi.fn>).mockResolvedValue({
liasses: [juneLiasse, mayLiasse],
rows: [{ liasse: juneLiasse, bulletin: juneBulletin }, { liasse: mayLiasse, bulletin: mayBulletin }],
});
const { ctx } = createAuthCtx({ role: "admin" });
const result = await appRouter.createCaller(ctx).salaires.list({ annee: 2026, mois: 6 });
expect(result.indicateurs).toEqual({ nombreSalaries: 1, totalBrutCents: 350000, totalEcartCents: 30000 });
expect(result.bulletins[0]).toMatchObject({ ecartBrutCents: 30000, brutPrecedentCents: 320000 });
expect(result.bulletins[0]?.explication).toContain("astreintes");
expect(JSON.stringify(result)).not.toContain("secret-key");
expect(JSON.stringify(result)).not.toContain("empreinteSha256");
expect(result.liasses[0]?.pdfPath).toBe("/api/salaires/liasses/10/pdf");
});
it("interdit l'index salarial aux profils standard et lecture seule", async () => {
const standard = createAuthCtx({ role: "standard" });
const readonly = createAuthCtx({ role: "readonly" });
await expect(appRouter.createCaller(standard.ctx).salaires.list({})).rejects.toThrow();
await expect(appRouter.createCaller(readonly.ctx).salaires.list({})).rejects.toThrow();
});
it("rejette un mois invalide avant tout accès aux données", async () => {
const { db } = await import("./db").then(m => ({ db: m }));
const { ctx } = createAuthCtx({ role: "admin" });
await expect(appRouter.createCaller(ctx).salaires.list({ mois: 13 })).rejects.toThrow();
expect(db.getSalairesMensuels).not.toHaveBeenCalled();
});
});

View File

@@ -1,4 +1,4 @@
import { and, asc, eq } from "drizzle-orm";
import { and, asc, desc, eq } from "drizzle-orm";
import { drizzle } from "drizzle-orm/mysql2";
import {
capexLignes,
@@ -21,6 +21,8 @@ import {
opexPostes,
opexValidated,
parametresApp,
salairesBulletins,
salairesLiasses,
users,
} from "../drizzle/schema";
import { isOpexEtablissementCode } from "../shared/opexValidation";
@@ -559,3 +561,151 @@ export async function setMasseSalarialeEvolution(
},
});
}
// ─────────────────────────────────────────────────────────────────────────────
// SALAIRES MENSUELS — Métadonnées de liasses et index de bulletins chiffrés
// ─────────────────────────────────────────────────────────────────────────────
export type SalaireBulletinImport = {
matricule: string;
nom: string;
prenom: string;
poste: string;
brutMensuelCents: number;
brutAvecPrimesCents: number;
primeAstreinteCents: number;
explicationEcart?: string | null;
numeroPage?: number | null;
};
export type SalaireLiasseCreateInput = {
annee: number;
mois: number;
nomFichier: string;
stockageKey: string;
empreinteSha256: string;
tailleOctets: number;
ivBase64: string;
authTagBase64: string;
importePar: number;
statutExtraction: "ready" | "partiel" | "a_controler";
erreurExtraction?: string | null;
bulletins: SalaireBulletinImport[];
};
/** Retourne une liasse de période pour empêcher une importation mensuelle multiple. */
export async function getSalaireLiasseByPeriod(annee: number, mois: number) {
const db = await getDb();
if (!db) throw new Error("Database not available");
const [liasse] = await db
.select()
.from(salairesLiasses)
.where(and(eq(salairesLiasses.annee, annee), eq(salairesLiasses.mois, mois)));
return liasse ?? null;
}
/** Retourne une liasse par identifiant, sans jamais renvoyer de fichier déchiffré. */
export async function getSalaireLiasseById(id: number) {
const db = await getDb();
if (!db) throw new Error("Database not available");
const [liasse] = await db.select().from(salairesLiasses).where(eq(salairesLiasses.id, id));
return liasse ?? null;
}
/**
* Persiste de façon atomique la référence chiffrée de la liasse et son index
* métier. Les octets du PDF restent exclusivement dans le stockage persistant.
*/
export async function createSalaireLiasse(input: SalaireLiasseCreateInput) {
const db = await getDb();
if (!db) throw new Error("Database not available");
return db.transaction(async (tx) => {
const [existing] = await tx
.select({ id: salairesLiasses.id })
.from(salairesLiasses)
.where(and(eq(salairesLiasses.annee, input.annee), eq(salairesLiasses.mois, input.mois)));
if (existing) throw new Error("Une liasse est déjà enregistrée pour cette période");
await tx.insert(salairesLiasses).values({
annee: input.annee,
mois: input.mois,
nomFichier: input.nomFichier,
stockageKey: input.stockageKey,
empreinteSha256: input.empreinteSha256,
tailleOctets: input.tailleOctets,
ivBase64: input.ivBase64,
authTagBase64: input.authTagBase64,
importePar: input.importePar,
statutExtraction: input.statutExtraction,
erreurExtraction: input.erreurExtraction ?? null,
});
const [liasse] = await tx
.select({ id: salairesLiasses.id })
.from(salairesLiasses)
.where(eq(salairesLiasses.stockageKey, input.stockageKey));
if (!liasse) throw new Error("Liasse non retrouvée après enregistrement");
if (input.bulletins.length > 0) {
await tx.insert(salairesBulletins).values(input.bulletins.map((bulletin) => ({
...bulletin,
liasseId: liasse.id,
})));
}
return { liasseId: liasse.id, bulletins: input.bulletins.length };
});
}
/**
* Met à jour l'index métier d'une liasse déjà archivée sans modifier sa clé de
* stockage, son empreinte ou ses paramètres de chiffrement.
*/
export async function replaceSalaireLiasseIndex(
liasseId: number,
input: Pick<SalaireLiasseCreateInput, "bulletins" | "statutExtraction" | "erreurExtraction">,
) {
const db = await getDb();
if (!db) throw new Error("Database not available");
return db.transaction(async (tx) => {
await tx.delete(salairesBulletins).where(eq(salairesBulletins.liasseId, liasseId));
await tx.update(salairesLiasses).set({
statutExtraction: input.statutExtraction,
erreurExtraction: input.erreurExtraction ?? null,
}).where(eq(salairesLiasses.id, liasseId));
if (input.bulletins.length > 0) {
await tx.insert(salairesBulletins).values(input.bulletins.map((bulletin) => ({ ...bulletin, liasseId })));
}
return { bulletins: input.bulletins.length };
});
}
/**
* Liste les liasses filtrées et les bulletins associés. La comparaison est
* réalisée dans le routeur à partir de ces montants normalisés en centimes.
*/
export async function getSalairesMensuels(annee?: number, mois?: number) {
const db = await getDb();
if (!db) throw new Error("Database not available");
const filter = annee && mois
? and(eq(salairesLiasses.annee, annee), eq(salairesLiasses.mois, mois))
: annee
? eq(salairesLiasses.annee, annee)
: mois
? eq(salairesLiasses.mois, mois)
: undefined;
const liassesQuery = db.select().from(salairesLiasses);
const liasses = filter
? await liassesQuery.where(filter).orderBy(desc(salairesLiasses.annee), desc(salairesLiasses.mois))
: await liassesQuery.orderBy(desc(salairesLiasses.annee), desc(salairesLiasses.mois));
const bulletinsQuery = db
.select({ liasse: salairesLiasses, bulletin: salairesBulletins })
.from(salairesBulletins)
.innerJoin(salairesLiasses, eq(salairesBulletins.liasseId, salairesLiasses.id));
const rows = filter
? await bulletinsQuery.where(filter).orderBy(asc(salairesBulletins.nom), asc(salairesBulletins.prenom))
: await bulletinsQuery.orderBy(desc(salairesLiasses.annee), desc(salairesLiasses.mois), asc(salairesBulletins.nom));
return { liasses, rows };
}

View File

@@ -0,0 +1,37 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { decryptPayrollPdf, encryptPayrollPdf, getPayrollEncryptionStatus } from "./payrollCrypto";
const originalKey = process.env.PAYROLL_ENCRYPTION_KEY;
describe.sequential("payrollCrypto", () => {
beforeEach(() => {
process.env.PAYROLL_ENCRYPTION_KEY = Buffer.alloc(32, 7).toString("base64");
});
afterEach(() => {
if (originalKey === undefined) delete process.env.PAYROLL_ENCRYPTION_KEY;
else process.env.PAYROLL_ENCRYPTION_KEY = originalKey;
});
it("chiffre puis déchiffre un PDF sans changer son contenu", () => {
const original = Buffer.from("%PDF-1.7 contenu de test", "utf8");
const encrypted = encryptPayrollPdf(original);
expect(encrypted.ciphertext).not.toEqual(original);
expect(decryptPayrollPdf(encrypted)).toEqual(original);
expect(getPayrollEncryptionStatus()).toEqual({ ready: true, algorithm: "AES-256-GCM" });
});
it("refuse un tag d'authentification altéré", () => {
const encrypted = encryptPayrollPdf(Buffer.from("%PDF-1.7 contenu de test", "utf8"));
const tamperedTag = Buffer.from(encrypted.authTagBase64, "base64");
tamperedTag[0] = tamperedTag[0]! ^ 0xff;
expect(() => decryptPayrollPdf({ ...encrypted, authTagBase64: tamperedTag.toString("base64") })).toThrow();
});
it("ne révèle aucune erreur détaillée lorsque la clé est invalide", () => {
process.env.PAYROLL_ENCRYPTION_KEY = "invalide";
expect(getPayrollEncryptionStatus()).toEqual({ ready: false, algorithm: "AES-256-GCM" });
});
});

62
server/payrollCrypto.ts Normal file
View File

@@ -0,0 +1,62 @@
import crypto from "node:crypto";
const ALGORITHM = "aes-256-gcm";
const IV_BYTES = 12;
const AUTH_TAG_BYTES = 16;
export type EncryptedPayrollFile = {
ciphertext: Buffer;
ivBase64: string;
authTagBase64: string;
};
/**
* Lit la clé dédiée aux bulletins. La clé ne transite jamais vers le client et
* doit être une valeur Base64 représentant précisément 32 octets AES-256.
*/
function getPayrollEncryptionKey(): Buffer {
const encodedKey = process.env.PAYROLL_ENCRYPTION_KEY?.trim();
if (!encodedKey) throw new Error("Clé de chiffrement des bulletins absente");
const key = Buffer.from(encodedKey, "base64");
if (key.length !== 32) {
throw new Error("La clé de chiffrement des bulletins doit contenir 32 octets");
}
return key;
}
/** Sondage sans divulgation de la clé, destiné aux contrôles administrateur. */
export function getPayrollEncryptionStatus() {
try {
getPayrollEncryptionKey();
return { ready: true as const, algorithm: "AES-256-GCM" };
} catch {
return { ready: false as const, algorithm: "AES-256-GCM" };
}
}
/** Chiffre un PDF avant son envoi vers le stockage persistant. */
export function encryptPayrollPdf(plaintext: Buffer): EncryptedPayrollFile {
const key = getPayrollEncryptionKey();
const iv = crypto.randomBytes(IV_BYTES);
const cipher = crypto.createCipheriv(ALGORITHM, key, iv, { authTagLength: AUTH_TAG_BYTES });
const ciphertext = Buffer.concat([cipher.update(plaintext), cipher.final()]);
return {
ciphertext,
ivBase64: iv.toString("base64"),
authTagBase64: cipher.getAuthTag().toString("base64"),
};
}
/** Déchiffre le document uniquement côté serveur, après contrôle des droits. */
export function decryptPayrollPdf(encrypted: EncryptedPayrollFile): Buffer {
const key = getPayrollEncryptionKey();
const iv = Buffer.from(encrypted.ivBase64, "base64");
const authTag = Buffer.from(encrypted.authTagBase64, "base64");
if (iv.length !== IV_BYTES || authTag.length !== AUTH_TAG_BYTES) {
throw new Error("Paramètres de chiffrement de bulletin invalides");
}
const decipher = crypto.createDecipheriv(ALGORITHM, key, iv, { authTagLength: AUTH_TAG_BYTES });
decipher.setAuthTag(authTag);
return Buffer.concat([decipher.update(encrypted.ciphertext), decipher.final()]);
}

View File

@@ -0,0 +1,80 @@
import { describe, expect, it } from "vitest";
import { extractPayrollBulletins } from "./payrollParser";
const SAMPLE_BULLETIN = `
##BULLETIN##05-2026##009999##DUPONT##TEST##
Matricule : 009999
Emploi : RESPONSABLE APPLICATIF Monsieur TEST DUPONT
Salaire de base (Forfait 218 jours) 3 650.00
Prime d'astreinte 200.00
Prime d'astreinte 200.00
Prime d'activité 150.00
Salaire brut 4 200.00
N° SS : ne-pas-extraire
Iban / Rib : ne-pas-extraire
Net à payer avant impôt sur le revenu 2 000.00
`;
describe("extractPayrollBulletins", () => {
it("n'extrait que les champs métier autorisés et additionne les astreintes", () => {
const result = extractPayrollBulletins(SAMPLE_BULLETIN);
expect(result).toMatchObject({ statutExtraction: "ready", erreurExtraction: null });
expect(result.bulletins).toEqual([
expect.objectContaining({
matricule: "009999",
nom: "DUPONT",
prenom: "TEST",
poste: "RESPONSABLE APPLICATIF",
brutMensuelCents: 365000,
brutAvecPrimesCents: 420000,
primeAstreinteCents: 40000,
}),
]);
expect(Object.keys(result.bulletins[0]!)).not.toContain("iban");
expect(JSON.stringify(result)).not.toContain("ne-pas-extraire");
});
it("signale une liasse non indexable sans prétendre perdre le PDF", () => {
expect(extractPayrollBulletins("contenu PDF non reconnu")).toEqual({
bulletins: [],
statutExtraction: "a_controler",
erreurExtraction: "Aucun bulletin na pu être indexé automatiquement. Le PDF reste conservé et consultable.",
});
});
it("indexe chaque salarié lorsque le texte PDF concatène les pages sans séparateur", () => {
const secondBulletin = SAMPLE_BULLETIN
.replace("##009999##DUPONT##TEST##", "##008888##MARTIN##EXEMPLE##")
.replaceAll("009999", "008888")
.replaceAll("TEST DUPONT", "EXEMPLE MARTIN");
const result = extractPayrollBulletins(`${SAMPLE_BULLETIN}\n${secondBulletin}`);
expect(result.bulletins).toHaveLength(2);
expect(result.bulletins.map((bulletin) => bulletin.matricule)).toEqual(["009999", "008888"]);
});
it("accepte un emploi suivi de la civilité par un seul espace, comme certains PDF extraits", () => {
const compacted = SAMPLE_BULLETIN.replace(" Monsieur", " Monsieur");
expect(extractPayrollBulletins(compacted).bulletins).toHaveLength(1);
});
it("lit un poste sur sa seule ligne lorsque la civilité est rendue à la ligne suivante", () => {
const separateCivilite = SAMPLE_BULLETIN.replace(" Monsieur TEST DUPONT", "\nMonsieur TEST DUPONT");
expect(extractPayrollBulletins(separateCivilite).bulletins).toHaveLength(1);
});
it("refuse une extraction d'emploi répartie sur plusieurs lignes pour ne jamais persister de donnée personnelle", () => {
const unsafeLayout = SAMPLE_BULLETIN.replace(
/Emploi\s*:[^\n]*/,
"Emploi :\nAdresse personnelle à ignorer\nRESPONSABLE APPLICATIF\nMonsieur TEST DUPONT",
);
expect(extractPayrollBulletins(unsafeLayout)).toMatchObject({
bulletins: [],
statutExtraction: "a_controler",
});
});
});

151
server/payrollParser.ts Normal file
View File

@@ -0,0 +1,151 @@
/**
* Extraction volontairement minimisée des liasses de paie. Aucune coordonnée,
* donnée bancaire, numéro de sécurité sociale ou rémunération nette n'est
* renvoyé ni persisté par ce module.
*/
export type ParsedPayrollBulletin = {
matricule: string;
nom: string;
prenom: string;
poste: string;
brutMensuelCents: number;
brutAvecPrimesCents: number;
primeAstreinteCents: number;
explicationEcart: string | null;
numeroPage: number;
};
export type PayrollExtractionResult = {
bulletins: ParsedPayrollBulletin[];
statutExtraction: "ready" | "partiel" | "a_controler";
erreurExtraction: string | null;
};
const MONEY_RE = /(?<![\d.])-?\s*\d{1,3}(?:[ \u00a0]\d{3})*\.\d{2}(?!\d)/g;
const HEADER_RE = /##BULLETIN##\d{2}-\d{4}##(\d+)##([^#]+)##([^#]+)##/g;
function amountsIn(line: string): number[] {
return (line.match(MONEY_RE) ?? []).map((raw) => Math.round(Number(raw.replace(/[ \u00a0]/g, "")) * 100));
}
function firstMatchingLine(lines: string[], prefix: string): string | null {
return lines.find((line) => line.trim().toLocaleLowerCase("fr-FR").startsWith(prefix)) ?? null;
}
function pagesForHeaders(text: string): Array<{ content: string; page: number }> {
const pages = text.split("\f");
const grouped: Array<{ content: string; page: number; matricule: string }> = [];
let current: { content: string; page: number; matricule: string } | null = null;
pages.forEach((page, index) => {
HEADER_RE.lastIndex = 0;
const headers: Array<{ index: number; length: number; matricule: string }> = [];
let header: RegExpExecArray | null;
while ((header = HEADER_RE.exec(page)) !== null) {
headers.push({ index: header.index, length: header[0].length, matricule: header[1] });
}
if (headers.length === 0 && current) {
current.content += `\n${page}`;
return;
}
if (headers.length === 0) return;
headers.forEach((entry, headerIndex) => {
const next = headers[headerIndex + 1];
const content = page.slice(entry.index, next?.index);
if (headerIndex === 0 && current?.matricule === entry.matricule) {
current.content += `\n${content}`;
} else {
current = { content, page: index + 1, matricule: entry.matricule };
grouped.push(current);
}
});
});
// Certains PDF ne contiennent pas de saut de page exploitable. On tente alors
// une extraction complète, qui sera marquée "à contrôler" sans en-tête fiable.
return grouped.length > 0 ? grouped.map(({ content, page }) => ({ content, page })) : [{ content: text, page: 1 }];
}
function getIdentity(text: string): { matricule: string; nom: string; prenom: string; poste: string } | null {
HEADER_RE.lastIndex = 0;
const header = HEADER_RE.exec(text);
const lines = text.split(/\r?\n/);
const matricule = /Matricule\s*:\s*(?:M|N|N°)?\s*(\d+)/.exec(text)?.[1] ?? header?.[1];
const emploiLine = lines.find((line) => /Emploi\s*:/i.test(line));
const poste = /Emploi\s*:\s*(.*)$/i.exec(emploiLine ?? "")?.[1]
?.replace(/\s+(?:Monsieur|Madame)\b.*$/i, "")
.trim();
if (!matricule || !poste) return null;
if (header?.[2] && header[3]) {
return {
matricule,
nom: header[2].trim(),
prenom: header[3].trim(),
poste,
};
}
const displayName = /Emploi\s*:[\s\S]*?(?:Monsieur|Madame)\s+([A-ZÀ-ÖØ-Ý' -]+)/.exec(text)?.[1]?.trim();
const names = displayName?.split(/\s+/).filter(Boolean) ?? [];
if (names.length < 2) return null;
return { matricule, prenom: names[0], nom: names.slice(1).join(" "), poste };
}
function parseOneBulletin(content: string, page: number): ParsedPayrollBulletin | null {
const lines = content.split(/\r?\n/).map((line) => line.trimEnd());
const identity = getIdentity(content);
const baseAmounts = amountsIn(firstMatchingLine(lines, "salaire de base") ?? "");
const grossAmounts = amountsIn(firstMatchingLine(lines, "salaire brut") ?? "");
if (!identity || baseAmounts.length === 0 || grossAmounts.length === 0) return null;
const primeAstreinteCents = lines
.filter((line) => line.trim().toLocaleLowerCase("fr-FR").startsWith("prime d'astreinte"))
.map((line) => amountsIn(line).at(-1) ?? 0)
.reduce((sum, amount) => sum + amount, 0);
const brutMensuelCents = baseAmounts.at(-1)!;
const brutAvecPrimesCents = grossAmounts.at(-1)!;
const otherVariableCents = brutAvecPrimesCents - brutMensuelCents - primeAstreinteCents;
const parts: string[] = [];
if (primeAstreinteCents !== 0) parts.push("prime dastreinte relevée");
if (otherVariableCents !== 0) parts.push("autres éléments de rémunération inclus dans le brut");
return {
...identity,
brutMensuelCents,
brutAvecPrimesCents,
primeAstreinteCents,
explicationEcart: parts.length ? parts.join(" ; ") : null,
numeroPage: page,
};
}
/**
* Transforme le texte d'une liasse PDF en index métier restreint. Toute absence
* de champ indispensable est signalée sans empêcher la conservation du PDF.
*/
export function extractPayrollBulletins(text: string): PayrollExtractionResult {
const candidates = pagesForHeaders(text);
const bulletins = candidates
.map(({ content, page }) => parseOneBulletin(content, page))
.filter((bulletin): bulletin is ParsedPayrollBulletin => bulletin !== null);
if (bulletins.length === 0) {
return {
bulletins: [],
statutExtraction: "a_controler",
erreurExtraction: "Aucun bulletin na pu être indexé automatiquement. Le PDF reste conservé et consultable.",
};
}
if (bulletins.length !== candidates.length) {
return {
bulletins,
statutExtraction: "partiel",
erreurExtraction: "Une ou plusieurs fiches nont pas pu être indexées automatiquement. Le PDF reste consultable.",
};
}
return { bulletins, statutExtraction: "ready", erreurExtraction: null };
}

39
server/payrollPdfText.ts Normal file
View File

@@ -0,0 +1,39 @@
import pdfParse from "pdf-parse/lib/pdf-parse.js";
type PdfTextItem = { str?: string; transform?: number[] };
type PdfPageData = { getTextContent: (options: { normalizeWhitespace: boolean; disableCombineTextItems: boolean }) => Promise<{ items: PdfTextItem[] }> };
/**
* Reconstruit les lignes en ordonnant les fragments de texte par coordonnées.
* Cette étape évite que les valeurs de colonnes distinctes (adresse, IBAN, etc.)
* soient concaténées dans l'intitulé de poste par l'extracteur PDF standard.
*/
export async function renderPayrollPage(pageData: unknown): Promise<string> {
const pdfPage = pageData as PdfPageData;
const textContent = await pdfPage.getTextContent({ normalizeWhitespace: false, disableCombineTextItems: false });
const fragments = textContent.items
.filter((item) => item.str && item.transform && item.transform.length >= 6)
.map((item) => ({ text: item.str!.trim(), x: item.transform![4]!, y: item.transform![5]! }))
.filter((item) => item.text.length > 0)
.sort((a, b) => Math.abs(b.y - a.y) > 0.75 ? b.y - a.y : a.x - b.x);
const lines: Array<{ y: number; items: Array<{ text: string; x: number }> }> = [];
fragments.forEach((fragment) => {
const previous = lines[lines.length - 1];
if (previous && Math.abs(previous.y - fragment.y) <= 0.75) {
previous.items.push({ text: fragment.text, x: fragment.x });
} else {
lines.push({ y: fragment.y, items: [{ text: fragment.text, x: fragment.x }] });
}
});
return `${lines
.map((line) => line.items.sort((a, b) => a.x - b.x).map((item) => item.text).join(" "))
.join("\n")}\f`;
}
/** Extrait le texte d'une liasse en préservant les limites de pages. */
export async function extractPayrollPdfText(pdf: Buffer): Promise<string> {
const parsed = await pdfParse(pdf, { pagerender: renderPayrollPage });
return parsed.text;
}

View File

@@ -8,6 +8,7 @@ import { sdk } from "./_core/sdk";
import { systemRouter } from "./_core/systemRouter";
import { adminProcedure, protectedProcedure, publicProcedure, router } from "./_core/trpc";
import { isOpexEtablissementCode, normalizeOpexEtablissementCode } from "../shared/opexValidation";
import { getPayrollEncryptionStatus } from "./payrollCrypto";
const writeProcedure = protectedProcedure.use(({ ctx, next }) => {
if (ctx.user.role === "readonly") {
@@ -22,6 +23,35 @@ const opexEtablissementCodeSchema = z
.transform(normalizeOpexEtablissementCode)
.refine(isOpexEtablissementCode, { message: "Code établissement OPEX invalide" });
const salaireFiltersSchema = z.object({
annee: z.number().int().min(2020).max(2100).optional(),
mois: z.number().int().min(1).max(12).optional(),
});
function periodKey(annee: number, mois: number) {
return annee * 12 + (mois - 1);
}
function describeMonthlySalaryDifference(
current: { brutMensuelCents: number; brutAvecPrimesCents: number; primeAstreinteCents: number },
previous: { brutMensuelCents: number; brutAvecPrimesCents: number; primeAstreinteCents: number } | undefined,
) {
if (!previous) return "Aucun bulletin de ce salarié disponible au mois précédent.";
const difference = current.brutAvecPrimesCents - previous.brutAvecPrimesCents;
if (difference === 0) return "Brut stable par rapport au mois précédent.";
const sign = (amount: number) => amount > 0 ? "hausse" : "baisse";
const centsLabel = (amount: number) => `${(Math.abs(amount) / 100).toLocaleString("fr-FR", { minimumFractionDigits: 2, maximumFractionDigits: 2 })} €`;
const reasons: string[] = [];
const baseDifference = current.brutMensuelCents - previous.brutMensuelCents;
const astreinteDifference = current.primeAstreinteCents - previous.primeAstreinteCents;
const otherDifference = difference - baseDifference - astreinteDifference;
if (baseDifference !== 0) reasons.push(`${sign(baseDifference)} de la base (${centsLabel(baseDifference)})`);
if (astreinteDifference !== 0) reasons.push(`${sign(astreinteDifference)} des astreintes (${centsLabel(astreinteDifference)})`);
if (otherDifference !== 0) reasons.push(`${sign(otherDifference)} d'autres éléments inclus au brut (${centsLabel(otherDifference)})`);
return reasons.length > 0 ? reasons.join(" ; ") : "Variation du brut à contrôler.";
}
export const appRouter = router({
system: systemRouter,
@@ -248,6 +278,88 @@ export const appRouter = router({
}),
}),
/** Informations de disponibilité du stockage salarial, sans divulgation de secret. */
salaires: router({
getSecurityStatus: adminProcedure.query(() => getPayrollEncryptionStatus()),
periods: adminProcedure.query(async () => {
const { liasses } = await db.getSalairesMensuels();
return liasses.map((liasse) => ({
id: liasse.id,
annee: liasse.annee,
mois: liasse.mois,
nomFichier: liasse.nomFichier,
tailleOctets: liasse.tailleOctets,
statutExtraction: liasse.statutExtraction,
erreurExtraction: liasse.erreurExtraction,
importeLe: liasse.importeLe,
}));
}),
/**
* Renvoie exclusivement l'index métier des bulletins, jamais les références
* de stockage, empreintes ou paramètres cryptographiques.
*/
list: adminProcedure.input(salaireFiltersSchema).query(async ({ input }) => {
const { liasses, rows } = await db.getSalairesMensuels();
const liassesByPeriod = new Map(liasses.map((liasse) => [periodKey(liasse.annee, liasse.mois), liasse]));
const rowsByLiasse = new Map<number, Map<string, typeof rows[number]["bulletin"]>>();
rows.forEach(({ liasse, bulletin }) => {
const byEmployee = rowsByLiasse.get(liasse.id) ?? new Map();
byEmployee.set(bulletin.matricule, bulletin);
rowsByLiasse.set(liasse.id, byEmployee);
});
const selectedLiasses = liasses.filter((liasse) =>
(!input.annee || liasse.annee === input.annee) && (!input.mois || liasse.mois === input.mois),
);
const selectedLiasseIds = new Set(selectedLiasses.map((liasse) => liasse.id));
const bulletins = rows
.filter(({ liasse }) => selectedLiasseIds.has(liasse.id))
.map(({ liasse, bulletin }) => {
const previousLiasse = liassesByPeriod.get(periodKey(liasse.annee, liasse.mois) - 1);
const previous = previousLiasse ? rowsByLiasse.get(previousLiasse.id)?.get(bulletin.matricule) : undefined;
const ecartBrutCents = previous ? bulletin.brutAvecPrimesCents - previous.brutAvecPrimesCents : null;
return {
liasseId: liasse.id,
annee: liasse.annee,
mois: liasse.mois,
matricule: bulletin.matricule,
nom: bulletin.nom,
prenom: bulletin.prenom,
poste: bulletin.poste,
brutMensuelCents: bulletin.brutMensuelCents,
brutAvecPrimesCents: bulletin.brutAvecPrimesCents,
primeAstreinteCents: bulletin.primeAstreinteCents,
brutPrecedentCents: previous?.brutAvecPrimesCents ?? null,
ecartBrutCents,
ecartBrutPourcentage: previous && previous.brutAvecPrimesCents !== 0
? Math.round((ecartBrutCents! / previous.brutAvecPrimesCents) * 10_000) / 100
: null,
explication: describeMonthlySalaryDifference(bulletin, previous),
numeroPage: bulletin.numeroPage,
};
});
const totalBrutCents = bulletins.reduce((total, bulletin) => total + bulletin.brutAvecPrimesCents, 0);
const totalEcartCents = bulletins.reduce((total, bulletin) => total + (bulletin.ecartBrutCents ?? 0), 0);
return {
liasses: selectedLiasses.map((liasse) => ({
id: liasse.id,
annee: liasse.annee,
mois: liasse.mois,
nomFichier: liasse.nomFichier,
tailleOctets: liasse.tailleOctets,
statutExtraction: liasse.statutExtraction,
erreurExtraction: liasse.erreurExtraction,
importeLe: liasse.importeLe,
pdfPath: `/api/salaires/liasses/${liasse.id}/pdf`,
})),
bulletins,
indicateurs: { totalBrutCents, totalEcartCents, nombreSalaries: bulletins.length },
};
}),
}),
// 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({

118
server/salairesHttp.test.ts Normal file
View File

@@ -0,0 +1,118 @@
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", () => ({
getSalaireLiasseByPeriod: vi.fn(),
createSalaireLiasse: vi.fn(),
getSalaireLiasseById: vi.fn(),
replaceSalaireLiasseIndex: vi.fn(),
}));
vi.mock("./storage", () => ({ storagePut: vi.fn(), storageGetBuffer: vi.fn() }));
vi.mock("pdf-parse/lib/pdf-parse.js", () => ({ default: vi.fn() }));
import { sdk } from "./_core/sdk";
import { createSalaireLiasse, getSalaireLiasseById, getSalaireLiasseByPeriod } from "./db";
import { encryptPayrollPdf } from "./payrollCrypto";
import { registerSalairesHttpRoutes } from "./salairesHttp";
import { storageGetBuffer, storagePut } from "./storage";
import pdfParse from "pdf-parse/lib/pdf-parse.js";
const EXTRACTED_TEXT = `##BULLETIN##05-2026##009999##DUPONT##TEST##
Matricule : 009999
Emploi : RESPONSABLE APPLICATIF Monsieur TEST DUPONT
Salaire de base 3 650.00
Salaire brut 3 650.00`;
function makeApp() {
const app = express();
registerSalairesHttpRoutes(app);
return app;
}
describe("routes HTTP Salaires", () => {
beforeEach(() => {
vi.clearAllMocks();
process.env.PAYROLL_ENCRYPTION_KEY = Buffer.alloc(32, 8).toString("base64");
(getSalaireLiasseByPeriod as ReturnType<typeof vi.fn>).mockResolvedValue(null);
});
it("interdit l'upload au profil non administrateur", async () => {
(sdk.authenticateRequest as ReturnType<typeof vi.fn>).mockResolvedValue({ id: 2, role: "standard" });
await request(makeApp())
.post("/api/salaires/liasses")
.field("annee", "2026")
.field("mois", "5")
.attach("file", Buffer.from("%PDF-1.7"), { filename: "mai.pdf", contentType: "application/pdf" })
.expect(403);
expect(createSalaireLiasse).not.toHaveBeenCalled();
expect(storagePut).not.toHaveBeenCalled();
});
it("chiffre et archive une liasse administrateur sans persister de donnée sensible", async () => {
(sdk.authenticateRequest as ReturnType<typeof vi.fn>).mockResolvedValue({ id: 1, role: "admin" });
(pdfParse as ReturnType<typeof vi.fn>).mockResolvedValue({ text: EXTRACTED_TEXT });
(storagePut as ReturnType<typeof vi.fn>).mockResolvedValue({ key: "salaires/2026/05/liasse_opaque.enc" });
(createSalaireLiasse as ReturnType<typeof vi.fn>).mockResolvedValue({ liasseId: 12, bulletins: 1 });
const response = await request(makeApp())
.post("/api/salaires/liasses")
.field("annee", "2026")
.field("mois", "5")
.attach("file", Buffer.from("%PDF-1.7 document de test"), { filename: "mai.pdf", contentType: "application/pdf" })
.expect(201);
expect(response.body).toMatchObject({ id: 12, bulletins: 1, statutExtraction: "ready" });
expect(createSalaireLiasse).toHaveBeenCalledWith(expect.objectContaining({
annee: 2026,
mois: 5,
importePar: 1,
stockageKey: "salaires/2026/05/liasse_opaque.enc",
bulletins: [expect.objectContaining({ matricule: "009999", brutMensuelCents: 365000 })],
}));
expect(JSON.stringify((createSalaireLiasse as ReturnType<typeof vi.fn>).mock.calls[0]?.[0])).not.toContain("N° SS");
});
it("sert un PDF déchiffré uniquement à un administrateur et sans mise en cache", async () => {
(sdk.authenticateRequest as ReturnType<typeof vi.fn>).mockResolvedValue({ id: 1, role: "admin" });
const plaintext = Buffer.from("%PDF-1.7 contenu de bulletin", "utf8");
const encrypted = encryptPayrollPdf(plaintext);
(getSalaireLiasseById as ReturnType<typeof vi.fn>).mockResolvedValue({ id: 30001, stockageKey: "opaque", ivBase64: encrypted.ivBase64, authTagBase64: encrypted.authTagBase64, annee: 2026, mois: 5 });
(storageGetBuffer as ReturnType<typeof vi.fn>).mockResolvedValue(encrypted.ciphertext);
const response = await request(makeApp()).get("/api/salaires/liasses/30001/pdf").expect(200);
expect(response.headers["cache-control"]).toContain("no-store");
expect(response.headers["x-content-type-options"]).toBe("nosniff");
expect(response.headers["content-type"]).toContain("application/pdf");
expect(response.body).toEqual(plaintext);
});
it("interdit la consultation d'une liasse au profil lecture seule", async () => {
(sdk.authenticateRequest as ReturnType<typeof vi.fn>).mockResolvedValue({ id: 3, role: "readonly" });
await request(makeApp()).get("/api/salaires/liasses/12/pdf").expect(403);
expect(getSalaireLiasseById).not.toHaveBeenCalled();
});
it("réindexe une liasse archivée sans téléverser ni remplacer son PDF", async () => {
const { replaceSalaireLiasseIndex } = await import("./db");
(sdk.authenticateRequest as ReturnType<typeof vi.fn>).mockResolvedValue({ id: 1, role: "admin" });
const encrypted = encryptPayrollPdf(Buffer.from("%PDF-1.7 contenu de bulletin", "utf8"));
(getSalaireLiasseById as ReturnType<typeof vi.fn>).mockResolvedValue({ id: 30001, stockageKey: "opaque", ivBase64: encrypted.ivBase64, authTagBase64: encrypted.authTagBase64, annee: 2026, mois: 5 });
(storageGetBuffer as ReturnType<typeof vi.fn>).mockResolvedValue(encrypted.ciphertext);
(pdfParse as ReturnType<typeof vi.fn>).mockResolvedValue({ text: EXTRACTED_TEXT });
(replaceSalaireLiasseIndex as ReturnType<typeof vi.fn>).mockResolvedValue({ bulletins: 1 });
await request(makeApp()).post("/api/salaires/liasses/30001/reindex").expect(200);
expect(replaceSalaireLiasseIndex).toHaveBeenCalledWith(30001, expect.objectContaining({
statutExtraction: "ready",
bulletins: [expect.objectContaining({ matricule: "009999" })],
}));
expect(storagePut).not.toHaveBeenCalled();
});
});

185
server/salairesHttp.ts Normal file
View File

@@ -0,0 +1,185 @@
import crypto from "node:crypto";
import type { Express, Request, Response } from "express";
import multer from "multer";
import { sdk } from "./_core/sdk";
import { createSalaireLiasse, getSalaireLiasseById, getSalaireLiasseByPeriod, replaceSalaireLiasseIndex } from "./db";
import { decryptPayrollPdf, encryptPayrollPdf } from "./payrollCrypto";
import { extractPayrollBulletins, type PayrollExtractionResult } from "./payrollParser";
import { extractPayrollPdfText } from "./payrollPdfText";
import { storageGetBuffer, storagePut } from "./storage";
const MAX_PDF_BYTES = 25 * 1024 * 1024;
const upload = multer({
storage: multer.memoryStorage(),
limits: { fileSize: MAX_PDF_BYTES, files: 1 },
});
function parsePeriod(value: unknown, min: number, max: number): number | null {
if (typeof value !== "string" || !/^\d+$/.test(value)) return null;
const parsed = Number(value);
return Number.isSafeInteger(parsed) && parsed >= min && parsed <= max ? parsed : null;
}
function isPdf(buffer: Buffer): boolean {
return buffer.subarray(0, 5).toString("ascii") === "%PDF-";
}
function safeUploadFilename(value: string | undefined): string {
const normalized = (value ?? "liasse.pdf").replace(/[\\/\0]/g, "").trim();
return normalized.slice(0, 255) || "liasse.pdf";
}
async function getAdminUser(req: Request, res: Response) {
let user;
try {
user = await sdk.authenticateRequest(req);
} catch {
res.status(401).json({ error: "Authentification requise." });
return null;
}
if (user.role !== "admin") {
res.status(403).json({ error: "Accès réservé aux administrateurs." });
return null;
}
return user;
}
/**
* Routes REST limitées aux transferts binaires. Toutes les données métier sont
* lues via tRPC ; aucune URL de stockage ne quitte ce module serveur.
*/
export function registerSalairesHttpRoutes(app: Express) {
app.post("/api/salaires/liasses", upload.single("file"), async (req, res) => {
const user = await getAdminUser(req, res);
if (!user) return;
const annee = parsePeriod(req.body?.annee, 2020, 2100);
const mois = parsePeriod(req.body?.mois, 1, 12);
const file = req.file;
if (!annee || !mois || !file || !isPdf(file.buffer)) {
res.status(400).json({ error: "Transmettez une période valide et un fichier PDF conforme." });
return;
}
const alreadyImported = await getSalaireLiasseByPeriod(annee, mois);
if (alreadyImported) {
res.status(409).json({ error: "Une liasse est déjà archivée pour cette période. Elle ne peut pas être écrasée." });
return;
}
// pdf-parse est exécuté en mémoire : le texte des bulletins n'est ni journalisé
// ni envoyé au navigateur. Un échec d'analyse laisse une archive consultable.
let extraction: PayrollExtractionResult = {
bulletins: [],
statutExtraction: "a_controler" as const,
erreurExtraction: "La liasse a été archivée, mais son index doit être contrôlé manuellement.",
};
try {
extraction = extractPayrollBulletins(await extractPayrollPdfText(file.buffer));
} catch {
// Le message reste générique afin de ne pas divulguer de détail du contenu PDF.
}
try {
const encrypted = encryptPayrollPdf(file.buffer);
const archive = await storagePut(
`salaires/${annee}/${String(mois).padStart(2, "0")}/liasse.pdf.enc`,
encrypted.ciphertext,
"application/octet-stream",
);
const persisted = await createSalaireLiasse({
annee,
mois,
nomFichier: safeUploadFilename(file.originalname),
stockageKey: archive.key,
empreinteSha256: crypto.createHash("sha256").update(file.buffer).digest("hex"),
tailleOctets: file.buffer.length,
ivBase64: encrypted.ivBase64,
authTagBase64: encrypted.authTagBase64,
importePar: user.id,
statutExtraction: extraction.statutExtraction,
erreurExtraction: extraction.erreurExtraction,
bulletins: extraction.bulletins,
});
res.status(201).json({
id: persisted.liasseId,
bulletins: persisted.bulletins,
statutExtraction: extraction.statutExtraction,
avertissement: extraction.erreurExtraction,
});
} catch (error) {
console.error("[Salaires] Échec de l'archivage d'une liasse", error instanceof Error ? error.message : "erreur inconnue");
res.status(500).json({ error: "L'archivage sécurisé de la liasse a échoué. Aucun enregistrement n'a été créé." });
}
});
app.post("/api/salaires/liasses/:id/reindex", async (req, res) => {
const user = await getAdminUser(req, res);
if (!user) return;
const id = parsePeriod(req.params.id, 1, Number.MAX_SAFE_INTEGER);
if (!id) {
res.status(400).json({ error: "Référence de liasse invalide." });
return;
}
const liasse = await getSalaireLiasseById(id);
if (!liasse) {
res.status(404).json({ error: "Liasse introuvable." });
return;
}
try {
const ciphertext = await storageGetBuffer(liasse.stockageKey);
const pdf = decryptPayrollPdf({ ciphertext, ivBase64: liasse.ivBase64, authTagBase64: liasse.authTagBase64 });
if (!isPdf(pdf)) throw new Error("Invalid decrypted PDF signature");
const extraction = extractPayrollBulletins(await extractPayrollPdfText(pdf));
if (extraction.bulletins.length === 0) {
res.status(422).json({ error: "Aucun bulletin n'a pu être indexé. L'archive et l'index existant sont conservés." });
return;
}
const result = await replaceSalaireLiasseIndex(id, extraction);
res.json({ bulletins: result.bulletins, statutExtraction: extraction.statutExtraction, avertissement: extraction.erreurExtraction });
} catch (error) {
console.error("[Salaires] Échec de la réindexation d'une liasse", error instanceof Error ? error.message : "erreur inconnue");
res.status(503).json({ error: "La liasse ne peut pas être réindexée pour le moment. Son archive reste inchangée." });
}
});
app.get("/api/salaires/liasses/:id/pdf", async (req, res) => {
const user = await getAdminUser(req, res);
if (!user) return;
const id = parsePeriod(req.params.id, 1, Number.MAX_SAFE_INTEGER);
if (!id) {
res.status(400).json({ error: "Référence de liasse invalide." });
return;
}
const liasse = await getSalaireLiasseById(id);
if (!liasse) {
res.status(404).json({ error: "Liasse introuvable." });
return;
}
try {
const ciphertext = await storageGetBuffer(liasse.stockageKey);
const pdf = decryptPayrollPdf({
ciphertext,
ivBase64: liasse.ivBase64,
authTagBase64: liasse.authTagBase64,
});
if (!isPdf(pdf)) throw new Error("Invalid decrypted PDF signature");
res.set({
"Content-Type": "application/pdf",
"Content-Length": String(pdf.length),
"Content-Disposition": `inline; filename="bulletins-${liasse.annee}-${String(liasse.mois).padStart(2, "0")}.pdf"`,
"Cache-Control": "no-store, private",
Pragma: "no-cache",
"X-Content-Type-Options": "nosniff",
});
res.send(pdf);
} catch (error) {
console.error("[Salaires] Échec de la consultation d'une liasse", error instanceof Error ? error.message : "erreur inconnue");
res.status(503).json({ error: "Le document ne peut pas être consulté pour le moment." });
}
});
}

View File

@@ -95,3 +95,15 @@ export async function storageGetSignedUrl(relKey: string): Promise<string> {
const { url } = (await resp.json()) as { url: string };
return url;
}
/**
* Télécharge un objet uniquement depuis le serveur. Cette primitive est utilisée
* par la consultation de bulletins afin de ne jamais communiquer de lien de
* stockage ni de document chiffré au navigateur.
*/
export async function storageGetBuffer(relKey: string): Promise<Buffer> {
const signedUrl = await storageGetSignedUrl(relKey);
const response = await fetch(signedUrl, { redirect: "follow" });
if (!response.ok) throw new Error(`Storage download failed (${response.status})`);
return Buffer.from(await response.arrayBuffer());
}

6
server/types/pdf-parse-internal.d.ts vendored Normal file
View File

@@ -0,0 +1,6 @@
declare module "pdf-parse/lib/pdf-parse.js" {
type PdfParseResult = { text: string };
type PdfParseOptions = { pagerender?: (pageData: unknown) => Promise<string> };
const pdfParse: (data: Buffer, options?: PdfParseOptions) => Promise<PdfParseResult>;
export default pdfParse;
}

58
todo.md
View File

@@ -138,5 +138,59 @@
## Correctif de fiabilité des logos de recette
- [x] Remplacer les logos de stockage indisponibles par des wordmarks sans dépendance externe
- [ ] Publier le correctif de wordmarks sur Gitea recette
- [ ] Vérifier visuellement la page de connexion de recette après publication
- [x] Publier le correctif de wordmarks sur Gitea recette
- [x] Vérifier visuellement la page de connexion de recette après publication
## Export Excel Masse salariale
- [x] Isoler, dans les bulletins, les seules primes d'astreinte à déduire du brut
- [x] Ajouter une colonne Astreintes à déduire modifiable pour chaque année
- [x] Calculer le brut hors primes par formule : brut annuel avec primes moins astreintes à déduire
- [x] Générer un tableau Excel 20252027 au format de la fenêtre Masse salariale
- [x] Vérifier les formules, les montants et la mise en forme du fichier Excel
## Mise à jour des astreintes selon le planning
- [x] Identifier les semaines d'astreinte 2025 et 2026 du planning pour les six salariés ciblés
- [x] Tracer explicitement les segments partiels à cheval entre exercices, exclus du décompte de semaine complète
- [ ] Intégrer toutes les semaines à cheval explicitement confirmées par les bulletins et vérifier les montants annuels
- [ ] Intégrer la semaine du 29/12/2025 de Jérôme Pascuzzi confirmée par le bulletin de janvier 2026
- [x] Relier le tableau principal aux totaux annuels d'astreinte des onglets dédiés
- [x] Vérifier les décomptes chiffrés et le fichier Excel final actualisé
## Finalisation prioritaire — Astreintes
- [x] Recalculer et livrer le fichier Excel selon la règle lundi-lundi complète
- [x] Utiliser exclusivement les semaines planifiées à 200 € comme source des astreintes 2025 et 2026
- [x] Supprimer les références au cumul de période des onglets et de la documentation
## Livraison prioritaire du classeur
- [x] Transmettre immédiatement la dernière version du fichier Excel avec les astreintes actualisées
## Fenêtre Salaires mensuelle
- [x] Confirmer les listes de données à afficher après téléversement d'une liasse mensuelle
- [x] Créer le stockage sécurisé des fichiers de paie et de leurs métadonnées
- [x] Créer l'import administrateur, l'extraction contrôlée et les procédures tRPC
- [x] Ajouter la fenêtre Salaires dans le menu Santinova et afficher les listes mensuelles
- [x] Tester les droits, le téléversement, l'extraction et les états d'interface
## Détails attendus — Salaires mensuels
- [x] Conserver chaque liasse PDF en stockage sécurisé et proposer sa consultation administrateur
- [x] Afficher salarié et salaire brut pour chaque mois, filtrables par année et mois
- [x] Comparer le brut d'un salarié au mois précédent avec explication basée sur les rubriques extraites
## Sécurité des bulletins de salaire
- [x] Chiffrer les fichiers PDF avant leur stockage dédié et ne conserver en base que les métadonnées et références
- [x] Contrôler que la consultation des PDFs chiffrés est réservée aux administrateurs
- [ ] Vérifier que les fichiers stockés restent disponibles après rebuild, mise à jour et réinitialisation des conteneurs
- [x] Générer et valider une clé AES-256 dédiée aux bulletins de salaire
## Déploiement de recette — Fenêtre Salaires
- [x] Vérifier les contrôles complets avant la publication
- [ ] Pousser la fenêtre Salaires vers Gitea et déclencher la CI/CD de recette
- [ ] Vérifier l'application Salaires déployée en HTTPS sur la recette
## Exécution en arrière-plan
- [ ] Finaliser la fenêtre Salaires et le déploiement de recette sans intervention utilisateur
## Validation bout en bout — Salaires
- [x] Importer une liasse PDF de contrôle avec un compte administrateur, contrôler son index, le filtre et la consultation sécurisée
- [x] Redémarrer le serveur puis relire une liasse archivée pour confirmer la persistance hors conteneur
- [x] Permettre à un administrateur de réindexer sans écraser une liasse déjà archivée