Checkpoint: Fenêtre administrateur Salaires finalisée : import multipart limité, chiffrement AES-256-GCM avant stockage persistant hors conteneur, métadonnées/index métier minimisés en base, consultation PDF déchiffrée côté serveur avec no-store, filtres année/mois et comparaison M-1 expliquée. Réindexation sûre des archives partielles sans écrasement du PDF. Validation réelle sur deux liasses mensuelles : index partiel signalé sans invention de données, comparaison affichée et lecture après redémarrage. 55 tests Vitest, TypeScript et build de production validés.
Some checks failed
Validation applicative / TypeScript, tests et build (push) Failing after 1m54s
Some checks failed
Validation applicative / TypeScript, tests et build (push) Failing after 1m54s
This commit is contained in:
@@ -20,6 +20,7 @@ import SoinsSante from "./pages/SoinsSante";
|
|||||||
import StExupery from "./pages/StExupery";
|
import StExupery from "./pages/StExupery";
|
||||||
import TableauBordFinance from "./pages/TableauBordFinance";
|
import TableauBordFinance from "./pages/TableauBordFinance";
|
||||||
import MasseSalariale from "./pages/MasseSalariale";
|
import MasseSalariale from "./pages/MasseSalariale";
|
||||||
|
import Salaires from "./pages/Salaires";
|
||||||
import { useAuth } from "./_core/hooks/useAuth";
|
import { useAuth } from "./_core/hooks/useAuth";
|
||||||
import { DashboardLayoutSkeleton } from "./components/DashboardLayoutSkeleton";
|
import { DashboardLayoutSkeleton } from "./components/DashboardLayoutSkeleton";
|
||||||
|
|
||||||
@@ -96,6 +97,9 @@ function Router() {
|
|||||||
<Route path="/masse-salariale">
|
<Route path="/masse-salariale">
|
||||||
<AdminRoute component={MasseSalariale} />
|
<AdminRoute component={MasseSalariale} />
|
||||||
</Route>
|
</Route>
|
||||||
|
<Route path="/salaires">
|
||||||
|
<AdminRoute component={Salaires} />
|
||||||
|
</Route>
|
||||||
|
|
||||||
<Route path="/404" component={NotFound} />
|
<Route path="/404" component={NotFound} />
|
||||||
<Route component={NotFound} />
|
<Route component={NotFound} />
|
||||||
|
|||||||
@@ -135,6 +135,7 @@ const NAV_SECTIONS: NavSection[] = [
|
|||||||
children: [
|
children: [
|
||||||
{ id: 'santinova', label: 'Vue SANTINOVA', icon: Heart, path: '/santinova' },
|
{ id: 'santinova', label: 'Vue SANTINOVA', icon: Heart, path: '/santinova' },
|
||||||
{ id: 'masse-salariale', label: 'Masse salariale', icon: Banknote, path: '/masse-salariale', adminOnly: true },
|
{ id: 'masse-salariale', label: 'Masse salariale', icon: Banknote, path: '/masse-salariale', adminOnly: true },
|
||||||
|
{ id: 'salaires', label: 'Salaires', icon: Banknote, path: '/salaires', adminOnly: true },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
226
client/src/pages/Salaires.tsx
Normal file
226
client/src/pages/Salaires.tsx
Normal 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 été 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
25
docs/salaires-securite.md
Normal 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.
|
||||||
36
drizzle/0008_living_silver_surfer.sql
Normal file
36
drizzle/0008_living_silver_surfer.sql
Normal 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`)
|
||||||
|
);
|
||||||
1
drizzle/0009_white_stardust.sql
Normal file
1
drizzle/0009_white_stardust.sql
Normal file
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE `salaires_liasses` ADD CONSTRAINT `salaires_liasses_periode_idx` UNIQUE(`annee`,`mois`);
|
||||||
2
drizzle/0010_brainy_oracle.sql
Normal file
2
drizzle/0010_brainy_oracle.sql
Normal 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;
|
||||||
1449
drizzle/meta/0008_snapshot.json
Normal file
1449
drizzle/meta/0008_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
1457
drizzle/meta/0009_snapshot.json
Normal file
1457
drizzle/meta/0009_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
1472
drizzle/meta/0010_snapshot.json
Normal file
1472
drizzle/meta/0010_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -57,6 +57,27 @@
|
|||||||
"when": 1787833910943,
|
"when": 1787833910943,
|
||||||
"tag": "0007_rich_tony_stark",
|
"tag": "0007_rich_tony_stark",
|
||||||
"breakpoints": true
|
"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
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -265,3 +265,64 @@ export const masseSalarialeEvolutions = mysqlTable("masse_salariale_evolutions",
|
|||||||
|
|
||||||
export type MasseSalarialeEvolution = typeof masseSalarialeEvolutions.$inferSelect;
|
export type MasseSalarialeEvolution = typeof masseSalarialeEvolutions.$inferSelect;
|
||||||
export type InsertMasseSalarialeEvolution = typeof masseSalarialeEvolutions.$inferInsert;
|
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;
|
||||||
|
|||||||
@@ -62,9 +62,11 @@
|
|||||||
"input-otp": "^1.4.2",
|
"input-otp": "^1.4.2",
|
||||||
"jose": "6.1.0",
|
"jose": "6.1.0",
|
||||||
"lucide-react": "^0.453.0",
|
"lucide-react": "^0.453.0",
|
||||||
|
"multer": "2.0.2",
|
||||||
"mysql2": "^3.15.0",
|
"mysql2": "^3.15.0",
|
||||||
"nanoid": "^5.1.5",
|
"nanoid": "^5.1.5",
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
|
"pdf-parse": "1.1.1",
|
||||||
"react": "^19.2.1",
|
"react": "^19.2.1",
|
||||||
"react-day-picker": "^9.11.1",
|
"react-day-picker": "^9.11.1",
|
||||||
"react-dom": "^19.2.1",
|
"react-dom": "^19.2.1",
|
||||||
@@ -88,9 +90,11 @@
|
|||||||
"@types/bcryptjs": "^3.0.0",
|
"@types/bcryptjs": "^3.0.0",
|
||||||
"@types/express": "4.17.21",
|
"@types/express": "4.17.21",
|
||||||
"@types/google.maps": "^3.58.1",
|
"@types/google.maps": "^3.58.1",
|
||||||
|
"@types/multer": "2.0.0",
|
||||||
"@types/node": "^24.7.0",
|
"@types/node": "^24.7.0",
|
||||||
"@types/react": "^19.2.1",
|
"@types/react": "^19.2.1",
|
||||||
"@types/react-dom": "^19.2.1",
|
"@types/react-dom": "^19.2.1",
|
||||||
|
"@types/supertest": "6.0.3",
|
||||||
"@vitejs/plugin-react": "^5.0.4",
|
"@vitejs/plugin-react": "^5.0.4",
|
||||||
"add": "^2.0.6",
|
"add": "^2.0.6",
|
||||||
"autoprefixer": "^10.4.20",
|
"autoprefixer": "^10.4.20",
|
||||||
@@ -99,6 +103,7 @@
|
|||||||
"pnpm": "^10.15.1",
|
"pnpm": "^10.15.1",
|
||||||
"postcss": "^8.4.47",
|
"postcss": "^8.4.47",
|
||||||
"prettier": "^3.6.2",
|
"prettier": "^3.6.2",
|
||||||
|
"supertest": "7.1.4",
|
||||||
"tailwindcss": "^4.1.14",
|
"tailwindcss": "^4.1.14",
|
||||||
"tsx": "^4.19.1",
|
"tsx": "^4.19.1",
|
||||||
"tw-animate-css": "^1.4.0",
|
"tw-animate-css": "^1.4.0",
|
||||||
|
|||||||
312
pnpm-lock.yaml
generated
312
pnpm-lock.yaml
generated
@@ -160,6 +160,9 @@ importers:
|
|||||||
lucide-react:
|
lucide-react:
|
||||||
specifier: ^0.453.0
|
specifier: ^0.453.0
|
||||||
version: 0.453.0(react@19.2.1)
|
version: 0.453.0(react@19.2.1)
|
||||||
|
multer:
|
||||||
|
specifier: 2.0.2
|
||||||
|
version: 2.0.2
|
||||||
mysql2:
|
mysql2:
|
||||||
specifier: ^3.15.0
|
specifier: ^3.15.0
|
||||||
version: 3.22.5(@types/node@24.7.0)
|
version: 3.22.5(@types/node@24.7.0)
|
||||||
@@ -169,6 +172,9 @@ importers:
|
|||||||
next-themes:
|
next-themes:
|
||||||
specifier: ^0.4.6
|
specifier: ^0.4.6
|
||||||
version: 0.4.6(react-dom@19.2.1(react@19.2.1))(react@19.2.1)
|
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:
|
react:
|
||||||
specifier: ^19.2.1
|
specifier: ^19.2.1
|
||||||
version: 19.2.1
|
version: 19.2.1
|
||||||
@@ -233,6 +239,9 @@ importers:
|
|||||||
'@types/google.maps':
|
'@types/google.maps':
|
||||||
specifier: ^3.58.1
|
specifier: ^3.58.1
|
||||||
version: 3.58.1
|
version: 3.58.1
|
||||||
|
'@types/multer':
|
||||||
|
specifier: 2.0.0
|
||||||
|
version: 2.0.0
|
||||||
'@types/node':
|
'@types/node':
|
||||||
specifier: ^24.7.0
|
specifier: ^24.7.0
|
||||||
version: 24.7.0
|
version: 24.7.0
|
||||||
@@ -242,6 +251,9 @@ importers:
|
|||||||
'@types/react-dom':
|
'@types/react-dom':
|
||||||
specifier: ^19.2.1
|
specifier: ^19.2.1
|
||||||
version: 19.2.1(@types/react@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':
|
'@vitejs/plugin-react':
|
||||||
specifier: ^5.0.4
|
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))
|
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:
|
prettier:
|
||||||
specifier: ^3.6.2
|
specifier: ^3.6.2
|
||||||
version: 3.6.2
|
version: 3.6.2
|
||||||
|
supertest:
|
||||||
|
specifier: 7.1.4
|
||||||
|
version: 7.1.4
|
||||||
tailwindcss:
|
tailwindcss:
|
||||||
specifier: ^4.1.14
|
specifier: ^4.1.14
|
||||||
version: 4.1.14
|
version: 4.1.14
|
||||||
@@ -1164,9 +1179,16 @@ packages:
|
|||||||
'@mermaid-js/parser@0.6.3':
|
'@mermaid-js/parser@0.6.3':
|
||||||
resolution: {integrity: sha512-lnjOhe7zyHjc+If7yT4zoedx2vo4sHaTmtkl1+or8BRTnCtDmcTpAjpzDSfCZrshM5bCoz0GyidzadJAH1xobA==}
|
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':
|
'@nodable/entities@2.1.1':
|
||||||
resolution: {integrity: sha512-Pig3HxDIoMgjdEH8OCf/dkcTmLFjJRjWuq8jSnklu284/TKOPibSRERmOykiwmyXTtv61mP+44f3GMx0tLAyjg==}
|
resolution: {integrity: sha512-Pig3HxDIoMgjdEH8OCf/dkcTmLFjJRjWuq8jSnklu284/TKOPibSRERmOykiwmyXTtv61mP+44f3GMx0tLAyjg==}
|
||||||
|
|
||||||
|
'@paralleldrive/cuid2@2.3.1':
|
||||||
|
resolution: {integrity: sha512-XO7cAxhnTZl0Yggq6jOgjiOHhbgcO4NqFqwSmQpjK3b6TEE6Uj/jfSk6wzYyemh3+I0sHirKSetjQwn5cZktFw==}
|
||||||
|
|
||||||
'@radix-ui/number@1.1.1':
|
'@radix-ui/number@1.1.1':
|
||||||
resolution: {integrity: sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g==}
|
resolution: {integrity: sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g==}
|
||||||
|
|
||||||
@@ -2099,6 +2121,9 @@ packages:
|
|||||||
'@types/connect@3.4.38':
|
'@types/connect@3.4.38':
|
||||||
resolution: {integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==}
|
resolution: {integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==}
|
||||||
|
|
||||||
|
'@types/cookiejar@2.1.5':
|
||||||
|
resolution: {integrity: sha512-he+DHOWReW0nghN24E1WUqM0efK4kI9oTqDm6XmK8ZPe2djZ90BSNdGnIyCLzCPw7/pogPlGbzI2wHGGmi4O/Q==}
|
||||||
|
|
||||||
'@types/d3-array@3.2.2':
|
'@types/d3-array@3.2.2':
|
||||||
resolution: {integrity: sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==}
|
resolution: {integrity: sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==}
|
||||||
|
|
||||||
@@ -2225,12 +2250,18 @@ packages:
|
|||||||
'@types/mdast@4.0.4':
|
'@types/mdast@4.0.4':
|
||||||
resolution: {integrity: sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==}
|
resolution: {integrity: sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==}
|
||||||
|
|
||||||
|
'@types/methods@1.1.4':
|
||||||
|
resolution: {integrity: sha512-ymXWVrDiCxTBE3+RIrrP533E70eA+9qu7zdWoHuOmGujkYtzf4HQF96b8nwHLqhuf4ykX61IGRIB38CC6/sImQ==}
|
||||||
|
|
||||||
'@types/mime@1.3.5':
|
'@types/mime@1.3.5':
|
||||||
resolution: {integrity: sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==}
|
resolution: {integrity: sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==}
|
||||||
|
|
||||||
'@types/ms@2.1.0':
|
'@types/ms@2.1.0':
|
||||||
resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==}
|
resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==}
|
||||||
|
|
||||||
|
'@types/multer@2.0.0':
|
||||||
|
resolution: {integrity: sha512-C3Z9v9Evij2yST3RSBktxP9STm6OdMc5uR1xF1SGr98uv8dUlAL2hqwrZ3GVB3uyMyiegnscEK6PGtYvNrjTjw==}
|
||||||
|
|
||||||
'@types/node@24.7.0':
|
'@types/node@24.7.0':
|
||||||
resolution: {integrity: sha512-IbKooQVqUBrlzWTi79E8Fw78l8k1RNtlDDNWsFZs7XonuQSJ8oNYfEeclhprUldXISRMLzBpILuKgPlIxm+/Yw==}
|
resolution: {integrity: sha512-IbKooQVqUBrlzWTi79E8Fw78l8k1RNtlDDNWsFZs7XonuQSJ8oNYfEeclhprUldXISRMLzBpILuKgPlIxm+/Yw==}
|
||||||
|
|
||||||
@@ -2257,6 +2288,12 @@ packages:
|
|||||||
'@types/serve-static@1.15.9':
|
'@types/serve-static@1.15.9':
|
||||||
resolution: {integrity: sha512-dOTIuqpWLyl3BBXU3maNQsS4A3zuuoYRNIvYSxxhebPfXg2mzWQEPne/nlJ37yOse6uGgR386uTpdsx4D0QZWA==}
|
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':
|
'@types/trusted-types@2.0.7':
|
||||||
resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==}
|
resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==}
|
||||||
|
|
||||||
@@ -2323,6 +2360,9 @@ packages:
|
|||||||
anynum@1.0.0:
|
anynum@1.0.0:
|
||||||
resolution: {integrity: sha512-xjR9/zBVnUOP6ztMIIgShjsxui80nQUQH+5xJnvrYLs+90bF25/KJqaAi8mk+B4RDtX1Nspi6fmp4YTEts8SfA==}
|
resolution: {integrity: sha512-xjR9/zBVnUOP6ztMIIgShjsxui80nQUQH+5xJnvrYLs+90bF25/KJqaAi8mk+B4RDtX1Nspi6fmp4YTEts8SfA==}
|
||||||
|
|
||||||
|
append-field@1.0.0:
|
||||||
|
resolution: {integrity: sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==}
|
||||||
|
|
||||||
aria-hidden@1.2.6:
|
aria-hidden@1.2.6:
|
||||||
resolution: {integrity: sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==}
|
resolution: {integrity: sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==}
|
||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
@@ -2330,6 +2370,9 @@ packages:
|
|||||||
array-flatten@1.1.1:
|
array-flatten@1.1.1:
|
||||||
resolution: {integrity: sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==}
|
resolution: {integrity: sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==}
|
||||||
|
|
||||||
|
asap@2.0.6:
|
||||||
|
resolution: {integrity: sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==}
|
||||||
|
|
||||||
assertion-error@2.0.1:
|
assertion-error@2.0.1:
|
||||||
resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==}
|
resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==}
|
||||||
engines: {node: '>=12'}
|
engines: {node: '>=12'}
|
||||||
@@ -2377,6 +2420,10 @@ packages:
|
|||||||
buffer-from@1.1.2:
|
buffer-from@1.1.2:
|
||||||
resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==}
|
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:
|
bytes@3.1.2:
|
||||||
resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==}
|
resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==}
|
||||||
engines: {node: '>= 0.8'}
|
engines: {node: '>= 0.8'}
|
||||||
@@ -2467,6 +2514,13 @@ packages:
|
|||||||
resolution: {integrity: sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==}
|
resolution: {integrity: sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==}
|
||||||
engines: {node: '>= 12'}
|
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:
|
confbox@0.1.8:
|
||||||
resolution: {integrity: sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==}
|
resolution: {integrity: sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==}
|
||||||
|
|
||||||
@@ -2495,6 +2549,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==}
|
resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
|
|
||||||
|
cookiejar@2.1.4:
|
||||||
|
resolution: {integrity: sha512-LDx6oHrK+PhzLKJU9j5S7/Y3jM/mUHvD/DeI1WQmJn652iPC5Y4TBzC9l+5OMOXlyTTA+SmVUPm0HQUwpD5Jqw==}
|
||||||
|
|
||||||
copy-anything@3.0.5:
|
copy-anything@3.0.5:
|
||||||
resolution: {integrity: sha512-yCEafptTtb4bk7GLEQoM8KVJpxAfdBJYaXyzQEgQQQgYrZiDp8SJmGKlYza6CYjEDNstAdNdKA3UuoULlEbS6w==}
|
resolution: {integrity: sha512-yCEafptTtb4bk7GLEQoM8KVJpxAfdBJYaXyzQEgQQQgYrZiDp8SJmGKlYza6CYjEDNstAdNdKA3UuoULlEbS6w==}
|
||||||
engines: {node: '>=12.13'}
|
engines: {node: '>=12.13'}
|
||||||
@@ -2691,6 +2748,14 @@ packages:
|
|||||||
supports-color:
|
supports-color:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
debug@3.2.7:
|
||||||
|
resolution: {integrity: sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==}
|
||||||
|
peerDependencies:
|
||||||
|
supports-color: '*'
|
||||||
|
peerDependenciesMeta:
|
||||||
|
supports-color:
|
||||||
|
optional: true
|
||||||
|
|
||||||
debug@4.4.3:
|
debug@4.4.3:
|
||||||
resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==}
|
resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==}
|
||||||
engines: {node: '>=6.0'}
|
engines: {node: '>=6.0'}
|
||||||
@@ -2743,6 +2808,9 @@ packages:
|
|||||||
devlop@1.1.0:
|
devlop@1.1.0:
|
||||||
resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==}
|
resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==}
|
||||||
|
|
||||||
|
dezalgo@1.0.4:
|
||||||
|
resolution: {integrity: sha512-rXSP0bf+5n0Qonsb+SVVfNfIsimO4HEtmnIpPHY8Q1UCzKlQrDMfdobr8nJOOsRgWCyMRqeSBQzmWUMq7zvVig==}
|
||||||
|
|
||||||
dom-helpers@5.2.1:
|
dom-helpers@5.2.1:
|
||||||
resolution: {integrity: sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==}
|
resolution: {integrity: sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==}
|
||||||
|
|
||||||
@@ -2972,6 +3040,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-6rxyATwPCkaFIL3JLqw8qXqMpIZ942pTX/tbQFkRsDGblS8tNGtlUauA/+mt6RUfqn/4MoEr+WDkYoIQbibWuQ==}
|
resolution: {integrity: sha512-6rxyATwPCkaFIL3JLqw8qXqMpIZ942pTX/tbQFkRsDGblS8tNGtlUauA/+mt6RUfqn/4MoEr+WDkYoIQbibWuQ==}
|
||||||
engines: {node: '>=6.0.0'}
|
engines: {node: '>=6.0.0'}
|
||||||
|
|
||||||
|
fast-safe-stringify@2.1.1:
|
||||||
|
resolution: {integrity: sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==}
|
||||||
|
|
||||||
fast-xml-builder@1.2.0:
|
fast-xml-builder@1.2.0:
|
||||||
resolution: {integrity: sha512-00aAWieqff+ZJhsXA4g1g7M8k+7AYoMUUHF+/zFb5U6Uv/P0Vl4QZo84/IcufzYalLuEj9928bXN9PbbFzMF0Q==}
|
resolution: {integrity: sha512-00aAWieqff+ZJhsXA4g1g7M8k+7AYoMUUHF+/zFb5U6Uv/P0Vl4QZo84/IcufzYalLuEj9928bXN9PbbFzMF0Q==}
|
||||||
|
|
||||||
@@ -3005,6 +3076,14 @@ packages:
|
|||||||
resolution: {integrity: sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==}
|
resolution: {integrity: sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==}
|
||||||
engines: {node: '>= 6'}
|
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:
|
forwarded@0.2.0:
|
||||||
resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==}
|
resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==}
|
||||||
engines: {node: '>= 0.6'}
|
engines: {node: '>= 0.6'}
|
||||||
@@ -3090,6 +3169,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==}
|
resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==}
|
||||||
engines: {node: '>= 0.4'}
|
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:
|
hast-util-from-dom@5.0.1:
|
||||||
resolution: {integrity: sha512-N+LqofjR2zuzTjCPzyDUdSshy4Ma6li7p/c3pA78uTwzFgENbgbUrm2ugwsOdcjI1muO+o6Dgzp9p8WHtn/39Q==}
|
resolution: {integrity: sha512-N+LqofjR2zuzTjCPzyDUdSshy4Ma6li7p/c3pA78uTwzFgENbgbUrm2ugwsOdcjI1muO+o6Dgzp9p8WHtn/39Q==}
|
||||||
|
|
||||||
@@ -3518,6 +3601,14 @@ packages:
|
|||||||
engines: {node: '>=4'}
|
engines: {node: '>=4'}
|
||||||
hasBin: true
|
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:
|
minipass@7.1.2:
|
||||||
resolution: {integrity: sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==}
|
resolution: {integrity: sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==}
|
||||||
engines: {node: '>=16 || 14 >=14.17'}
|
engines: {node: '>=16 || 14 >=14.17'}
|
||||||
@@ -3529,6 +3620,10 @@ packages:
|
|||||||
mitt@3.0.1:
|
mitt@3.0.1:
|
||||||
resolution: {integrity: sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==}
|
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:
|
mlly@1.8.0:
|
||||||
resolution: {integrity: sha512-l8D9ODSRWLe2KHJSifWGwBqpTZXIXTeo8mlKjY+E2HAakaTeNpqAyBZ8GSqLzHgw4XmHmC8whvpjJNMbFZN7/g==}
|
resolution: {integrity: sha512-l8D9ODSRWLe2KHJSifWGwBqpTZXIXTeo8mlKjY+E2HAakaTeNpqAyBZ8GSqLzHgw4XmHmC8whvpjJNMbFZN7/g==}
|
||||||
|
|
||||||
@@ -3547,6 +3642,10 @@ packages:
|
|||||||
ms@2.1.3:
|
ms@2.1.3:
|
||||||
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
|
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:
|
mysql2@3.22.5:
|
||||||
resolution: {integrity: sha512-95uZ2TrPWAZdwpB3vvvDbmEMcNG8yIeNCyu6GUcr/QnWEE/wXm7+mhOCsdQfWQDTV7qYT/PDUZ4U4UPP4AsXqQ==}
|
resolution: {integrity: sha512-95uZ2TrPWAZdwpB3vvvDbmEMcNG8yIeNCyu6GUcr/QnWEE/wXm7+mhOCsdQfWQDTV7qYT/PDUZ4U4UPP4AsXqQ==}
|
||||||
engines: {node: '>= 8.0'}
|
engines: {node: '>= 8.0'}
|
||||||
@@ -3577,6 +3676,9 @@ packages:
|
|||||||
react: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc
|
react: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc
|
||||||
react-dom: ^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:
|
node-releases@2.0.23:
|
||||||
resolution: {integrity: sha512-cCmFDMSm26S6tQSDpBCg/NR8NENrVPhAJSf+XbxBG4rPFaaonlEoE9wHQmun+cls499TQGSb7ZyPBRlzgKfpeg==}
|
resolution: {integrity: sha512-cCmFDMSm26S6tQSDpBCg/NR8NENrVPhAJSf+XbxBG4rPFaaonlEoE9wHQmun+cls499TQGSb7ZyPBRlzgKfpeg==}
|
||||||
|
|
||||||
@@ -3596,6 +3698,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==}
|
resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==}
|
||||||
engines: {node: '>= 0.8'}
|
engines: {node: '>= 0.8'}
|
||||||
|
|
||||||
|
once@1.4.0:
|
||||||
|
resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==}
|
||||||
|
|
||||||
oniguruma-parser@0.12.1:
|
oniguruma-parser@0.12.1:
|
||||||
resolution: {integrity: sha512-8Unqkvk1RYc6yq2WBYRj4hdnsAxVze8i7iPfQr8e4uSP3tRv0rpZcbGUDvxfQQcdwHt/e9PrMvGCsa8OqG9X3w==}
|
resolution: {integrity: sha512-8Unqkvk1RYc6yq2WBYRj4hdnsAxVze8i7iPfQr8e4uSP3tRv0rpZcbGUDvxfQQcdwHt/e9PrMvGCsa8OqG9X3w==}
|
||||||
|
|
||||||
@@ -3635,6 +3740,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==}
|
resolution: {integrity: sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==}
|
||||||
engines: {node: '>= 14.16'}
|
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:
|
picocolors@1.1.1:
|
||||||
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
|
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
|
||||||
|
|
||||||
@@ -3695,6 +3804,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg==}
|
resolution: {integrity: sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg==}
|
||||||
engines: {node: '>=0.6'}
|
engines: {node: '>=0.6'}
|
||||||
|
|
||||||
|
qs@6.15.3:
|
||||||
|
resolution: {integrity: sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==}
|
||||||
|
engines: {node: '>=0.6'}
|
||||||
|
|
||||||
quansync@0.2.11:
|
quansync@0.2.11:
|
||||||
resolution: {integrity: sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA==}
|
resolution: {integrity: sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA==}
|
||||||
|
|
||||||
@@ -3791,6 +3904,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-DGrYcCWK7tvYMnWh79yrPHt+vdx9tY+1gPZa7nJQtO/p8bLTDaHp4dzwEhQB7pZ4Xe3ok4XKuEPrVuc+wlpkmw==}
|
resolution: {integrity: sha512-DGrYcCWK7tvYMnWh79yrPHt+vdx9tY+1gPZa7nJQtO/p8bLTDaHp4dzwEhQB7pZ4Xe3ok4XKuEPrVuc+wlpkmw==}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
|
|
||||||
|
readable-stream@3.6.2:
|
||||||
|
resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==}
|
||||||
|
engines: {node: '>= 6'}
|
||||||
|
|
||||||
recharts-scale@0.4.5:
|
recharts-scale@0.4.5:
|
||||||
resolution: {integrity: sha512-kivNFO+0OcUNu7jQquLXAxz1FIwZj8nrj+YkOKc5694NbjCvcT6aSZiIzNzd2Kul4o4rTto8QVR9lMNtxD4G1w==}
|
resolution: {integrity: sha512-kivNFO+0OcUNu7jQquLXAxz1FIwZj8nrj+YkOKc5694NbjCvcT6aSZiIzNzd2Kul4o4rTto8QVR9lMNtxD4G1w==}
|
||||||
|
|
||||||
@@ -3886,6 +4003,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==}
|
resolution: {integrity: sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==}
|
||||||
engines: {node: '>= 0.4'}
|
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:
|
side-channel-map@1.0.1:
|
||||||
resolution: {integrity: sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==}
|
resolution: {integrity: sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
@@ -3898,6 +4019,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==}
|
resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
|
|
||||||
|
side-channel@1.1.1:
|
||||||
|
resolution: {integrity: sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==}
|
||||||
|
engines: {node: '>= 0.4'}
|
||||||
|
|
||||||
siginfo@2.0.0:
|
siginfo@2.0.0:
|
||||||
resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==}
|
resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==}
|
||||||
|
|
||||||
@@ -3944,6 +4069,13 @@ packages:
|
|||||||
peerDependencies:
|
peerDependencies:
|
||||||
react: ^18.0.0 || ^19.0.0
|
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:
|
stringify-entities@4.0.4:
|
||||||
resolution: {integrity: sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==}
|
resolution: {integrity: sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==}
|
||||||
|
|
||||||
@@ -3959,10 +4091,18 @@ packages:
|
|||||||
stylis@4.3.6:
|
stylis@4.3.6:
|
||||||
resolution: {integrity: sha512-yQ3rwFWRfwNUY7H5vpU0wfdkNSnvnJinhF9830Swlaxl03zsOjCfmX0ugac+3LtK0lYSgwL/KXc8oYL3mG4YFQ==}
|
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:
|
superjson@1.13.3:
|
||||||
resolution: {integrity: sha512-mJiVjfd2vokfDxsQPOwJ/PtanO87LhpYY88ubI5dUB1Ab58Txbyje3+jpm+/83R/fevaq/107NNhtYBLuoTrFg==}
|
resolution: {integrity: sha512-mJiVjfd2vokfDxsQPOwJ/PtanO87LhpYY88ubI5dUB1Ab58Txbyje3+jpm+/83R/fevaq/107NNhtYBLuoTrFg==}
|
||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
|
|
||||||
|
supertest@7.1.4:
|
||||||
|
resolution: {integrity: sha512-tjLPs7dVyqgItVFirHYqe2T+MfWc2VOBQ8QFKKbWTA3PU7liZR8zoSpAi/C1k1ilm9RsXIKYf197oap9wXGVYg==}
|
||||||
|
engines: {node: '>=14.18.0'}
|
||||||
|
|
||||||
tailwind-merge@3.3.1:
|
tailwind-merge@3.3.1:
|
||||||
resolution: {integrity: sha512-gBXpgUm/3rp1lMZZrM/w7D8GKqshif0zAymAhbCyIt8KMe+0v9DQ7cdYLR4FHH/cKpdTXb+A/tKKU3eolfsI+g==}
|
resolution: {integrity: sha512-gBXpgUm/3rp1lMZZrM/w7D8GKqshif0zAymAhbCyIt8KMe+0v9DQ7cdYLR4FHH/cKpdTXb+A/tKKU3eolfsI+g==}
|
||||||
|
|
||||||
@@ -4044,6 +4184,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==}
|
resolution: {integrity: sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==}
|
||||||
engines: {node: '>= 0.6'}
|
engines: {node: '>= 0.6'}
|
||||||
|
|
||||||
|
typedarray@0.0.6:
|
||||||
|
resolution: {integrity: sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==}
|
||||||
|
|
||||||
typescript@5.9.3:
|
typescript@5.9.3:
|
||||||
resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==}
|
resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==}
|
||||||
engines: {node: '>=14.17'}
|
engines: {node: '>=14.17'}
|
||||||
@@ -4292,6 +4435,9 @@ packages:
|
|||||||
peerDependencies:
|
peerDependencies:
|
||||||
react: '>=16.8.0'
|
react: '>=16.8.0'
|
||||||
|
|
||||||
|
wrappy@1.0.2:
|
||||||
|
resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==}
|
||||||
|
|
||||||
xlsx@0.18.5:
|
xlsx@0.18.5:
|
||||||
resolution: {integrity: sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==}
|
resolution: {integrity: sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==}
|
||||||
engines: {node: '>=0.8'}
|
engines: {node: '>=0.8'}
|
||||||
@@ -4301,6 +4447,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-k8KO9hrMyNk6tUWqUfkTEZbezRRpONVOzUTnc97VnCvyj6Tf9lyUR9EDAIeiVLv56jsMcoXEwjW8Kv5yPY52lw==}
|
resolution: {integrity: sha512-k8KO9hrMyNk6tUWqUfkTEZbezRRpONVOzUTnc97VnCvyj6Tf9lyUR9EDAIeiVLv56jsMcoXEwjW8Kv5yPY52lw==}
|
||||||
engines: {node: '>=16.0.0'}
|
engines: {node: '>=16.0.0'}
|
||||||
|
|
||||||
|
xtend@4.0.2:
|
||||||
|
resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==}
|
||||||
|
engines: {node: '>=0.4'}
|
||||||
|
|
||||||
yallist@3.1.1:
|
yallist@3.1.1:
|
||||||
resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==}
|
resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==}
|
||||||
|
|
||||||
@@ -5077,8 +5227,14 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
langium: 3.3.1
|
langium: 3.3.1
|
||||||
|
|
||||||
|
'@noble/hashes@1.8.0': {}
|
||||||
|
|
||||||
'@nodable/entities@2.1.1': {}
|
'@nodable/entities@2.1.1': {}
|
||||||
|
|
||||||
|
'@paralleldrive/cuid2@2.3.1':
|
||||||
|
dependencies:
|
||||||
|
'@noble/hashes': 1.8.0
|
||||||
|
|
||||||
'@radix-ui/number@1.1.1': {}
|
'@radix-ui/number@1.1.1': {}
|
||||||
|
|
||||||
'@radix-ui/primitive@1.1.3': {}
|
'@radix-ui/primitive@1.1.3': {}
|
||||||
@@ -6017,6 +6173,8 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
'@types/node': 24.7.0
|
'@types/node': 24.7.0
|
||||||
|
|
||||||
|
'@types/cookiejar@2.1.5': {}
|
||||||
|
|
||||||
'@types/d3-array@3.2.2': {}
|
'@types/d3-array@3.2.2': {}
|
||||||
|
|
||||||
'@types/d3-axis@3.0.6':
|
'@types/d3-axis@3.0.6':
|
||||||
@@ -6174,10 +6332,16 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
'@types/unist': 3.0.3
|
'@types/unist': 3.0.3
|
||||||
|
|
||||||
|
'@types/methods@1.1.4': {}
|
||||||
|
|
||||||
'@types/mime@1.3.5': {}
|
'@types/mime@1.3.5': {}
|
||||||
|
|
||||||
'@types/ms@2.1.0': {}
|
'@types/ms@2.1.0': {}
|
||||||
|
|
||||||
|
'@types/multer@2.0.0':
|
||||||
|
dependencies:
|
||||||
|
'@types/express': 4.17.21
|
||||||
|
|
||||||
'@types/node@24.7.0':
|
'@types/node@24.7.0':
|
||||||
dependencies:
|
dependencies:
|
||||||
undici-types: 7.14.0
|
undici-types: 7.14.0
|
||||||
@@ -6209,6 +6373,18 @@ snapshots:
|
|||||||
'@types/node': 24.7.0
|
'@types/node': 24.7.0
|
||||||
'@types/send': 0.17.5
|
'@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':
|
'@types/trusted-types@2.0.7':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
@@ -6283,12 +6459,16 @@ snapshots:
|
|||||||
|
|
||||||
anynum@1.0.0: {}
|
anynum@1.0.0: {}
|
||||||
|
|
||||||
|
append-field@1.0.0: {}
|
||||||
|
|
||||||
aria-hidden@1.2.6:
|
aria-hidden@1.2.6:
|
||||||
dependencies:
|
dependencies:
|
||||||
tslib: 2.8.1
|
tslib: 2.8.1
|
||||||
|
|
||||||
array-flatten@1.1.1: {}
|
array-flatten@1.1.1: {}
|
||||||
|
|
||||||
|
asap@2.0.6: {}
|
||||||
|
|
||||||
assertion-error@2.0.1: {}
|
assertion-error@2.0.1: {}
|
||||||
|
|
||||||
asynckit@0.4.0: {}
|
asynckit@0.4.0: {}
|
||||||
@@ -6348,6 +6528,10 @@ snapshots:
|
|||||||
|
|
||||||
buffer-from@1.1.2: {}
|
buffer-from@1.1.2: {}
|
||||||
|
|
||||||
|
busboy@1.6.0:
|
||||||
|
dependencies:
|
||||||
|
streamsearch: 1.1.0
|
||||||
|
|
||||||
bytes@3.1.2: {}
|
bytes@3.1.2: {}
|
||||||
|
|
||||||
cac@6.7.14: {}
|
cac@6.7.14: {}
|
||||||
@@ -6435,6 +6619,15 @@ snapshots:
|
|||||||
|
|
||||||
commander@8.3.0: {}
|
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.1.8: {}
|
||||||
|
|
||||||
confbox@0.2.2: {}
|
confbox@0.2.2: {}
|
||||||
@@ -6453,6 +6646,8 @@ snapshots:
|
|||||||
|
|
||||||
cookie@1.1.1: {}
|
cookie@1.1.1: {}
|
||||||
|
|
||||||
|
cookiejar@2.1.4: {}
|
||||||
|
|
||||||
copy-anything@3.0.5:
|
copy-anything@3.0.5:
|
||||||
dependencies:
|
dependencies:
|
||||||
is-what: 4.1.16
|
is-what: 4.1.16
|
||||||
@@ -6665,6 +6860,10 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
ms: 2.0.0
|
ms: 2.0.0
|
||||||
|
|
||||||
|
debug@3.2.7:
|
||||||
|
dependencies:
|
||||||
|
ms: 2.1.3
|
||||||
|
|
||||||
debug@4.4.3:
|
debug@4.4.3:
|
||||||
dependencies:
|
dependencies:
|
||||||
ms: 2.1.3
|
ms: 2.1.3
|
||||||
@@ -6699,6 +6898,11 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
dequal: 2.0.3
|
dequal: 2.0.3
|
||||||
|
|
||||||
|
dezalgo@1.0.4:
|
||||||
|
dependencies:
|
||||||
|
asap: 2.0.6
|
||||||
|
wrappy: 1.0.2
|
||||||
|
|
||||||
dom-helpers@5.2.1:
|
dom-helpers@5.2.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@babel/runtime': 7.28.4
|
'@babel/runtime': 7.28.4
|
||||||
@@ -6942,6 +7146,8 @@ snapshots:
|
|||||||
|
|
||||||
fast-equals@5.3.2: {}
|
fast-equals@5.3.2: {}
|
||||||
|
|
||||||
|
fast-safe-stringify@2.1.1: {}
|
||||||
|
|
||||||
fast-xml-builder@1.2.0:
|
fast-xml-builder@1.2.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
path-expression-matcher: 1.5.0
|
path-expression-matcher: 1.5.0
|
||||||
@@ -6980,6 +7186,20 @@ snapshots:
|
|||||||
hasown: 2.0.2
|
hasown: 2.0.2
|
||||||
mime-types: 2.1.35
|
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: {}
|
forwarded@0.2.0: {}
|
||||||
|
|
||||||
frac@1.1.2: {}
|
frac@1.1.2: {}
|
||||||
@@ -7050,6 +7270,10 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
function-bind: 1.1.2
|
function-bind: 1.1.2
|
||||||
|
|
||||||
|
hasown@2.0.4:
|
||||||
|
dependencies:
|
||||||
|
function-bind: 1.1.2
|
||||||
|
|
||||||
hast-util-from-dom@5.0.1:
|
hast-util-from-dom@5.0.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/hast': 3.0.4
|
'@types/hast': 3.0.4
|
||||||
@@ -7750,6 +7974,10 @@ snapshots:
|
|||||||
|
|
||||||
mime@1.6.0: {}
|
mime@1.6.0: {}
|
||||||
|
|
||||||
|
mime@2.6.0: {}
|
||||||
|
|
||||||
|
minimist@1.2.8: {}
|
||||||
|
|
||||||
minipass@7.1.2: {}
|
minipass@7.1.2: {}
|
||||||
|
|
||||||
minizlib@3.1.0:
|
minizlib@3.1.0:
|
||||||
@@ -7758,6 +7986,10 @@ snapshots:
|
|||||||
|
|
||||||
mitt@3.0.1: {}
|
mitt@3.0.1: {}
|
||||||
|
|
||||||
|
mkdirp@0.5.6:
|
||||||
|
dependencies:
|
||||||
|
minimist: 1.2.8
|
||||||
|
|
||||||
mlly@1.8.0:
|
mlly@1.8.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
acorn: 8.15.0
|
acorn: 8.15.0
|
||||||
@@ -7777,6 +8009,16 @@ snapshots:
|
|||||||
|
|
||||||
ms@2.1.3: {}
|
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):
|
mysql2@3.22.5(@types/node@24.7.0):
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/node': 24.7.0
|
'@types/node': 24.7.0
|
||||||
@@ -7804,6 +8046,8 @@ snapshots:
|
|||||||
react: 19.2.1
|
react: 19.2.1
|
||||||
react-dom: 19.2.1(react@19.2.1)
|
react-dom: 19.2.1(react@19.2.1)
|
||||||
|
|
||||||
|
node-ensure@0.0.0: {}
|
||||||
|
|
||||||
node-releases@2.0.23: {}
|
node-releases@2.0.23: {}
|
||||||
|
|
||||||
normalize-range@0.1.2: {}
|
normalize-range@0.1.2: {}
|
||||||
@@ -7816,6 +8060,10 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
ee-first: 1.1.1
|
ee-first: 1.1.1
|
||||||
|
|
||||||
|
once@1.4.0:
|
||||||
|
dependencies:
|
||||||
|
wrappy: 1.0.2
|
||||||
|
|
||||||
oniguruma-parser@0.12.1: {}
|
oniguruma-parser@0.12.1: {}
|
||||||
|
|
||||||
oniguruma-to-es@4.3.3:
|
oniguruma-to-es@4.3.3:
|
||||||
@@ -7854,6 +8102,13 @@ snapshots:
|
|||||||
|
|
||||||
pathval@2.0.1: {}
|
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: {}
|
picocolors@1.1.1: {}
|
||||||
|
|
||||||
picomatch@4.0.3: {}
|
picomatch@4.0.3: {}
|
||||||
@@ -7915,6 +8170,11 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
side-channel: 1.1.0
|
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: {}
|
quansync@0.2.11: {}
|
||||||
|
|
||||||
range-parser@1.2.1: {}
|
range-parser@1.2.1: {}
|
||||||
@@ -8017,6 +8277,12 @@ snapshots:
|
|||||||
|
|
||||||
react@19.2.1: {}
|
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:
|
recharts-scale@0.4.5:
|
||||||
dependencies:
|
dependencies:
|
||||||
decimal.js-light: 2.5.1
|
decimal.js-light: 2.5.1
|
||||||
@@ -8201,6 +8467,11 @@ snapshots:
|
|||||||
es-errors: 1.3.0
|
es-errors: 1.3.0
|
||||||
object-inspect: 1.13.4
|
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:
|
side-channel-map@1.0.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
call-bound: 1.0.4
|
call-bound: 1.0.4
|
||||||
@@ -8224,6 +8495,14 @@ snapshots:
|
|||||||
side-channel-map: 1.0.1
|
side-channel-map: 1.0.1
|
||||||
side-channel-weakmap: 1.0.2
|
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: {}
|
siginfo@2.0.0: {}
|
||||||
|
|
||||||
sonner@2.0.7(react-dom@19.2.1(react@19.2.1))(react@19.2.1):
|
sonner@2.0.7(react-dom@19.2.1(react@19.2.1))(react@19.2.1):
|
||||||
@@ -8274,6 +8553,12 @@ snapshots:
|
|||||||
- '@types/react'
|
- '@types/react'
|
||||||
- supports-color
|
- supports-color
|
||||||
|
|
||||||
|
streamsearch@1.1.0: {}
|
||||||
|
|
||||||
|
string_decoder@1.3.0:
|
||||||
|
dependencies:
|
||||||
|
safe-buffer: 5.2.1
|
||||||
|
|
||||||
stringify-entities@4.0.4:
|
stringify-entities@4.0.4:
|
||||||
dependencies:
|
dependencies:
|
||||||
character-entities-html4: 2.1.0
|
character-entities-html4: 2.1.0
|
||||||
@@ -8293,10 +8578,31 @@ snapshots:
|
|||||||
|
|
||||||
stylis@4.3.6: {}
|
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:
|
superjson@1.13.3:
|
||||||
dependencies:
|
dependencies:
|
||||||
copy-anything: 3.0.5
|
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: {}
|
tailwind-merge@3.3.1: {}
|
||||||
|
|
||||||
tailwindcss-animate@1.0.7(tailwindcss@4.1.14):
|
tailwindcss-animate@1.0.7(tailwindcss@4.1.14):
|
||||||
@@ -8364,6 +8670,8 @@ snapshots:
|
|||||||
media-typer: 0.3.0
|
media-typer: 0.3.0
|
||||||
mime-types: 2.1.35
|
mime-types: 2.1.35
|
||||||
|
|
||||||
|
typedarray@0.0.6: {}
|
||||||
|
|
||||||
typescript@5.9.3: {}
|
typescript@5.9.3: {}
|
||||||
|
|
||||||
ufo@1.6.1: {}
|
ufo@1.6.1: {}
|
||||||
@@ -8612,6 +8920,8 @@ snapshots:
|
|||||||
regexparam: 3.0.0
|
regexparam: 3.0.0
|
||||||
use-sync-external-store: 1.6.0(react@19.2.1)
|
use-sync-external-store: 1.6.0(react@19.2.1)
|
||||||
|
|
||||||
|
wrappy@1.0.2: {}
|
||||||
|
|
||||||
xlsx@0.18.5:
|
xlsx@0.18.5:
|
||||||
dependencies:
|
dependencies:
|
||||||
adler-32: 1.3.1
|
adler-32: 1.3.1
|
||||||
@@ -8624,6 +8934,8 @@ snapshots:
|
|||||||
|
|
||||||
xml-naming@0.1.0: {}
|
xml-naming@0.1.0: {}
|
||||||
|
|
||||||
|
xtend@4.0.2: {}
|
||||||
|
|
||||||
yallist@3.1.1: {}
|
yallist@3.1.1: {}
|
||||||
|
|
||||||
yallist@5.0.0: {}
|
yallist@5.0.0: {}
|
||||||
|
|||||||
28
scripts/diagnosePayrollExtraction.ts
Normal file
28
scripts/diagnosePayrollExtraction.ts
Normal 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),
|
||||||
|
}));
|
||||||
@@ -6,6 +6,7 @@ import { createExpressMiddleware } from "@trpc/server/adapters/express";
|
|||||||
import { registerOAuthRoutes } from "./oauth";
|
import { registerOAuthRoutes } from "./oauth";
|
||||||
import { registerStorageProxy } from "./storageProxy";
|
import { registerStorageProxy } from "./storageProxy";
|
||||||
import { appRouter } from "../routers";
|
import { appRouter } from "../routers";
|
||||||
|
import { registerSalairesHttpRoutes } from "../salairesHttp";
|
||||||
import { createContext } from "./context";
|
import { createContext } from "./context";
|
||||||
import { serveStatic, setupVite } from "./vite";
|
import { serveStatic, setupVite } from "./vite";
|
||||||
|
|
||||||
@@ -36,6 +37,7 @@ async function startServer() {
|
|||||||
app.use(express.urlencoded({ limit: "50mb", extended: true }));
|
app.use(express.urlencoded({ limit: "50mb", extended: true }));
|
||||||
registerStorageProxy(app);
|
registerStorageProxy(app);
|
||||||
registerOAuthRoutes(app);
|
registerOAuthRoutes(app);
|
||||||
|
registerSalairesHttpRoutes(app);
|
||||||
// tRPC API
|
// tRPC API
|
||||||
app.use(
|
app.use(
|
||||||
"/api/trpc",
|
"/api/trpc",
|
||||||
|
|||||||
@@ -74,6 +74,7 @@ vi.mock("./db", async (importOriginal) => {
|
|||||||
getMasseSalariale: vi.fn(),
|
getMasseSalariale: vi.fn(),
|
||||||
importMasseSalariale: vi.fn(),
|
importMasseSalariale: vi.fn(),
|
||||||
setMasseSalarialeEvolution: vi.fn(),
|
setMasseSalarialeEvolution: vi.fn(),
|
||||||
|
getSalairesMensuels: vi.fn(),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -434,3 +435,72 @@ describe("masseSalariale", () => {
|
|||||||
expect(db.setMasseSalarialeEvolution).not.toHaveBeenCalled();
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
152
server/db.ts
152
server/db.ts
@@ -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 { drizzle } from "drizzle-orm/mysql2";
|
||||||
import {
|
import {
|
||||||
capexLignes,
|
capexLignes,
|
||||||
@@ -21,6 +21,8 @@ import {
|
|||||||
opexPostes,
|
opexPostes,
|
||||||
opexValidated,
|
opexValidated,
|
||||||
parametresApp,
|
parametresApp,
|
||||||
|
salairesBulletins,
|
||||||
|
salairesLiasses,
|
||||||
users,
|
users,
|
||||||
} from "../drizzle/schema";
|
} from "../drizzle/schema";
|
||||||
import { isOpexEtablissementCode } from "../shared/opexValidation";
|
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 };
|
||||||
|
}
|
||||||
|
|||||||
37
server/payrollCrypto.test.ts
Normal file
37
server/payrollCrypto.test.ts
Normal 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
62
server/payrollCrypto.ts
Normal 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()]);
|
||||||
|
}
|
||||||
80
server/payrollParser.test.ts
Normal file
80
server/payrollParser.test.ts
Normal 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 n’a 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
151
server/payrollParser.ts
Normal 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 d’astreinte 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 n’a pu être indexé automatiquement. Le PDF reste conservé et consultable.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (bulletins.length !== candidates.length) {
|
||||||
|
return {
|
||||||
|
bulletins,
|
||||||
|
statutExtraction: "partiel",
|
||||||
|
erreurExtraction: "Une ou plusieurs fiches n’ont pas pu être indexées automatiquement. Le PDF reste consultable.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { bulletins, statutExtraction: "ready", erreurExtraction: null };
|
||||||
|
}
|
||||||
39
server/payrollPdfText.ts
Normal file
39
server/payrollPdfText.ts
Normal 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;
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ import { sdk } from "./_core/sdk";
|
|||||||
import { systemRouter } from "./_core/systemRouter";
|
import { systemRouter } from "./_core/systemRouter";
|
||||||
import { adminProcedure, protectedProcedure, publicProcedure, router } from "./_core/trpc";
|
import { adminProcedure, protectedProcedure, publicProcedure, router } from "./_core/trpc";
|
||||||
import { isOpexEtablissementCode, normalizeOpexEtablissementCode } from "../shared/opexValidation";
|
import { isOpexEtablissementCode, normalizeOpexEtablissementCode } from "../shared/opexValidation";
|
||||||
|
import { getPayrollEncryptionStatus } from "./payrollCrypto";
|
||||||
|
|
||||||
const writeProcedure = protectedProcedure.use(({ ctx, next }) => {
|
const writeProcedure = protectedProcedure.use(({ ctx, next }) => {
|
||||||
if (ctx.user.role === "readonly") {
|
if (ctx.user.role === "readonly") {
|
||||||
@@ -22,6 +23,35 @@ const opexEtablissementCodeSchema = z
|
|||||||
.transform(normalizeOpexEtablissementCode)
|
.transform(normalizeOpexEtablissementCode)
|
||||||
.refine(isOpexEtablissementCode, { message: "Code établissement OPEX invalide" });
|
.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({
|
export const appRouter = router({
|
||||||
system: systemRouter,
|
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
|
// 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.
|
// sous-routeur n'est accessible aux profils standard ou lecture seule.
|
||||||
masseSalariale: router({
|
masseSalariale: router({
|
||||||
|
|||||||
118
server/salairesHttp.test.ts
Normal file
118
server/salairesHttp.test.ts
Normal 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
185
server/salairesHttp.ts
Normal 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." });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -95,3 +95,15 @@ export async function storageGetSignedUrl(relKey: string): Promise<string> {
|
|||||||
const { url } = (await resp.json()) as { url: string };
|
const { url } = (await resp.json()) as { url: string };
|
||||||
return url;
|
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
6
server/types/pdf-parse-internal.d.ts
vendored
Normal 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;
|
||||||
|
}
|
||||||
54
todo.md
54
todo.md
@@ -140,3 +140,57 @@
|
|||||||
- [x] Remplacer les logos de stockage indisponibles par des wordmarks sans dépendance externe
|
- [x] Remplacer les logos de stockage indisponibles par des wordmarks sans dépendance externe
|
||||||
- [x] Publier le correctif de wordmarks sur Gitea recette
|
- [x] Publier le correctif de wordmarks sur Gitea recette
|
||||||
- [x] Vérifier visuellement la page de connexion de recette après publication
|
- [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 2025–2027 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
|
||||||
|
|||||||
Reference in New Issue
Block a user