Compare commits
14 Commits
0a00e70a9f
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8312cf25fc | ||
|
|
28570234d0 | ||
|
|
5c9618b204 | ||
|
|
f759032793 | ||
|
|
b2c8580382 | ||
|
|
6447389cb8 | ||
|
|
7ee4e848d2 | ||
|
|
eeac2de481 | ||
|
|
0e59dac29f | ||
|
|
193f7ce32a | ||
|
|
7698e9f680 | ||
|
|
558582d358 | ||
|
|
82de3181df | ||
|
|
8a58f2c2bd |
6
app.json
6
app.json
@@ -1,14 +1,14 @@
|
||||
{
|
||||
"id": "itinova-budget-si",
|
||||
"name": "Gestion Budget Informatique",
|
||||
"name": "Gestion budget informatique",
|
||||
"category": "SANTINOVA",
|
||||
"urls": {
|
||||
"recette": "https://budget-si.recette.santinova-soft.org",
|
||||
"prod": "https://budget-si.santinova-soft.org"
|
||||
},
|
||||
"containerName": "itinova-budget-si-app",
|
||||
"image": "images/budget-si.png",
|
||||
"giteaRepo": "itinova-budget-si",
|
||||
"image": "images/budget-si.jpg",
|
||||
"giteaRepo": "manus-admin/itinova-budget-si",
|
||||
"giteaOwner": "manus-admin",
|
||||
"ci": {
|
||||
"required": true
|
||||
|
||||
@@ -20,6 +20,7 @@ import SoinsSante from "./pages/SoinsSante";
|
||||
import StExupery from "./pages/StExupery";
|
||||
import TableauBordFinance from "./pages/TableauBordFinance";
|
||||
import MasseSalariale from "./pages/MasseSalariale";
|
||||
import Salaires from "./pages/Salaires";
|
||||
import { useAuth } from "./_core/hooks/useAuth";
|
||||
import { DashboardLayoutSkeleton } from "./components/DashboardLayoutSkeleton";
|
||||
|
||||
@@ -96,6 +97,9 @@ function Router() {
|
||||
<Route path="/masse-salariale">
|
||||
<AdminRoute component={MasseSalariale} />
|
||||
</Route>
|
||||
<Route path="/salaires">
|
||||
<AdminRoute component={Salaires} />
|
||||
</Route>
|
||||
|
||||
<Route path="/404" component={NotFound} />
|
||||
<Route component={NotFound} />
|
||||
|
||||
@@ -135,6 +135,7 @@ const NAV_SECTIONS: NavSection[] = [
|
||||
children: [
|
||||
{ id: 'santinova', label: 'Vue SANTINOVA', icon: Heart, path: '/santinova' },
|
||||
{ id: 'masse-salariale', label: 'Masse salariale', icon: Banknote, path: '/masse-salariale', adminOnly: true },
|
||||
{ id: 'salaires', label: 'Salaires', icon: Banknote, path: '/salaires', adminOnly: true },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -1,261 +1,212 @@
|
||||
// MasseSalariale.tsx — Données de rémunération SANTINOVA (accès administrateur)
|
||||
// MasseSalariale.tsx — Prévisionnel et réel SANTINOVA, accès administrateur.
|
||||
|
||||
import { Fragment, useMemo, useState } from 'react';
|
||||
import { AppSidebar } from '../components/AppSidebar';
|
||||
import { MasseSalarialeDataState } from '../components/MasseSalarialeDataState';
|
||||
import { trpc } from '../lib/trpc';
|
||||
import { getMasseSalarialeUiState, getMasseSalarialeYearSummary } from '../lib/masseSalariale';
|
||||
import { toast } from 'sonner';
|
||||
import {
|
||||
Banknote,
|
||||
CalendarDays,
|
||||
CircleAlert,
|
||||
ShieldCheck,
|
||||
Users,
|
||||
} from 'lucide-react';
|
||||
import { Fragment, useMemo, useState } from "react";
|
||||
import { AppSidebar } from "../components/AppSidebar";
|
||||
import { MasseSalarialeDataState } from "../components/MasseSalarialeDataState";
|
||||
import { trpc } from "../lib/trpc";
|
||||
import { getMasseSalarialeUiState } from "../lib/masseSalariale";
|
||||
import { toast } from "sonner";
|
||||
import { Banknote, CalendarDays, CircleAlert, FileSpreadsheet, LockKeyhole, ShieldCheck, Upload, Users } from "lucide-react";
|
||||
|
||||
const ANNEES = [2025, 2026, 2027] as const;
|
||||
|
||||
function formatEuros(cents?: number | null) {
|
||||
if (cents === undefined || cents === null) return '—';
|
||||
return new Intl.NumberFormat('fr-FR', {
|
||||
style: 'currency',
|
||||
currency: 'EUR',
|
||||
maximumFractionDigits: 0,
|
||||
if (cents === undefined || cents === null) return "—";
|
||||
return new Intl.NumberFormat("fr-FR", {
|
||||
style: "currency",
|
||||
currency: "EUR",
|
||||
minimumFractionDigits: cents % 100 === 0 ? 0 : 2,
|
||||
maximumFractionDigits: 2,
|
||||
}).format(cents / 100);
|
||||
}
|
||||
|
||||
function formatPercent(bps?: number | null) {
|
||||
if (bps === undefined || bps === null) return '—';
|
||||
return `${(bps / 100).toLocaleString('fr-FR', { maximumFractionDigits: 2 })} %`;
|
||||
if (bps === undefined || bps === null) return "—";
|
||||
return `${(bps / 100).toLocaleString("fr-FR", { maximumFractionDigits: 2 })} %`;
|
||||
}
|
||||
|
||||
function formatDate(isoDate?: string | null) {
|
||||
if (!isoDate) return '—';
|
||||
const [year, month, day] = isoDate.split('-');
|
||||
if (!isoDate) return "—";
|
||||
const [year, month, day] = isoDate.split("-");
|
||||
return year && month && day ? `${day}/${month}/${year}` : isoDate;
|
||||
}
|
||||
|
||||
function evolutionKey(salarieId: number, annee: number) {
|
||||
function keyFor(salarieId: number, annee: number) {
|
||||
return `${salarieId}-${annee}`;
|
||||
}
|
||||
|
||||
function formatMonths(months: number[] | undefined) {
|
||||
if (!months?.length) return "—";
|
||||
return months.length === 12 ? "12 mois" : `${months.length} mois`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Les revenus annuels sont strictement ceux présents dans les bulletins
|
||||
* importés. L'évolution, elle, reste une hypothèse utilisateur distincte et
|
||||
* n'engendre donc jamais un montant 2027 artificiel.
|
||||
* Les prévisions viennent du classeur de référence. Le réel n'est affiché que
|
||||
* lorsqu'il provient d'une clôture ou d'une liasse PDF effectivement archivée.
|
||||
*/
|
||||
export default function MasseSalariale() {
|
||||
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
|
||||
const [editingKey, setEditingKey] = useState<string | null>(null);
|
||||
const [evolutionDraft, setEvolutionDraft] = useState('');
|
||||
const [primeDraft, setPrimeDraft] = useState("");
|
||||
const [evolutionDraft, setEvolutionDraft] = useState("");
|
||||
const [workbookFile, setWorkbookFile] = useState<File | null>(null);
|
||||
const [isImportingWorkbook, setIsImportingWorkbook] = useState(false);
|
||||
const utils = trpc.useUtils();
|
||||
const { data, isLoading, isError } = trpc.masseSalariale.get.useQuery();
|
||||
const setEvolution = trpc.masseSalariale.setEvolution.useMutation({
|
||||
const saveAdjustments = trpc.masseSalariale.setForecastAdjustments.useMutation({
|
||||
onSuccess: async () => {
|
||||
await utils.masseSalariale.get.invalidate();
|
||||
setEditingKey(null);
|
||||
setEvolutionDraft('');
|
||||
toast.success('Évolution salariale enregistrée');
|
||||
toast.success("Prévision mise à jour");
|
||||
},
|
||||
onError: () => toast.error("L'évolution salariale n'a pas pu être enregistrée"),
|
||||
onError: (error) => toast.error(error.message || "La prévision n'a pas pu être enregistrée"),
|
||||
});
|
||||
|
||||
const remunerationByKey = useMemo(
|
||||
() => new Map((data?.remunerations ?? []).map((row) => [evolutionKey(row.salarieId, row.annee), row])),
|
||||
[data?.remunerations],
|
||||
const forecastByKey = useMemo(
|
||||
() => new Map((data?.forecasts ?? []).map((row) => [keyFor(row.salarieId, row.annee), row])),
|
||||
[data?.forecasts],
|
||||
);
|
||||
const evolutionByKey = useMemo(
|
||||
() => new Map((data?.evolutions ?? []).map((row) => [evolutionKey(row.salarieId, row.annee), row])),
|
||||
[data?.evolutions],
|
||||
const actualByKey = useMemo(
|
||||
() => new Map((data?.actuals ?? []).map((row) => [keyFor(row.salarieId, row.annee), row])),
|
||||
[data?.actuals],
|
||||
);
|
||||
const totals = useMemo(() => new Map(ANNEES.map((annee) => {
|
||||
const forecasts = (data?.forecasts ?? []).filter((row) => row.annee === annee);
|
||||
const actuals = (data?.actuals ?? []).filter((row) => row.annee === annee);
|
||||
return [annee, {
|
||||
forecast: forecasts.reduce((sum, row) => sum + row.brutAvecPrimesCents, 0),
|
||||
actual: actuals.reduce((sum, row) => sum + row.brutAvecPrimesCents, 0),
|
||||
forecastCount: forecasts.length,
|
||||
actualCount: actuals.length,
|
||||
actualMonths: Math.max(0, ...actuals.map((row) => row.moisDisponibles.length)),
|
||||
}];
|
||||
})), [data?.forecasts, data?.actuals]);
|
||||
|
||||
const uiState = getMasseSalarialeUiState(isLoading, isError, data?.salaries.length ?? 0);
|
||||
const totalsByYear = useMemo(
|
||||
() => new Map(ANNEES.map((annee) => [
|
||||
annee,
|
||||
getMasseSalarialeYearSummary(data?.remunerations ?? [], annee),
|
||||
])),
|
||||
[data?.remunerations],
|
||||
);
|
||||
|
||||
const startEvolutionEdit = (salarieId: number, annee: number) => {
|
||||
const key = evolutionKey(salarieId, annee);
|
||||
const current = evolutionByKey.get(key)?.evolutionSalarialeBps;
|
||||
setEditingKey(key);
|
||||
setEvolutionDraft(current === undefined || current === null ? '' : String(current / 100));
|
||||
const startEdit = (salarieId: number, annee: number) => {
|
||||
const forecast = forecastByKey.get(keyFor(salarieId, annee));
|
||||
if (!forecast?.editable) return;
|
||||
setEditingKey(keyFor(salarieId, annee));
|
||||
setPrimeDraft((forecast.primeCents / 100).toFixed(2));
|
||||
setEvolutionDraft((forecast.evolutionCents / 100).toFixed(2));
|
||||
};
|
||||
|
||||
const saveEvolution = (salarieId: number, annee: number) => {
|
||||
const normalized = evolutionDraft.trim().replace(',', '.');
|
||||
const percentage = Number(normalized);
|
||||
if (!normalized || !Number.isFinite(percentage) || percentage < -100 || percentage > 100) {
|
||||
toast.error('Saisissez une évolution comprise entre -100 % et 100 %.');
|
||||
const saveEdit = (salarieId: number, annee: number) => {
|
||||
const normalize = (value: string) => Number(value.trim().replace(",", "."));
|
||||
const prime = normalize(primeDraft);
|
||||
const evolution = normalize(evolutionDraft);
|
||||
if (!Number.isFinite(prime) || !Number.isFinite(evolution) || Math.abs(prime) > 1_000_000 || Math.abs(evolution) > 1_000_000) {
|
||||
toast.error("Saisissez des montants compris entre -1 000 000 € et 1 000 000 €.");
|
||||
return;
|
||||
}
|
||||
setEvolution.mutate({ salarieId, annee, evolutionSalarialeBps: Math.round(percentage * 100) });
|
||||
saveAdjustments.mutate({
|
||||
salarieId,
|
||||
annee,
|
||||
primeCents: Math.round(prime * 100),
|
||||
evolutionCents: Math.round(evolution * 100),
|
||||
});
|
||||
};
|
||||
|
||||
const importWorkbook = async () => {
|
||||
if (!workbookFile) {
|
||||
toast.error("Choisissez le fichier Excel de référence avant de lancer l'import.");
|
||||
return;
|
||||
}
|
||||
if (!workbookFile.name.toLowerCase().endsWith(".xlsx") || workbookFile.size > 5 * 1024 * 1024) {
|
||||
toast.error("Le fichier doit être un .xlsx de 5 Mo maximum.");
|
||||
return;
|
||||
}
|
||||
setIsImportingWorkbook(true);
|
||||
try {
|
||||
const payload = new FormData();
|
||||
payload.append("file", workbookFile);
|
||||
const response = await fetch("/api/masse-salariale/previsionnels", { method: "POST", body: payload, credentials: "include" });
|
||||
const result = await response.json() as { salaries?: number; remunerations?: number; error?: string };
|
||||
if (!response.ok) throw new Error(result.error ?? "L'import a échoué.");
|
||||
await utils.masseSalariale.get.invalidate();
|
||||
setWorkbookFile(null);
|
||||
toast.success(`Fichier de référence importé : ${result.salaries ?? 0} salarié(s), ${result.remunerations ?? 0} prévision(s).`);
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : "L'import a échoué.");
|
||||
} finally {
|
||||
setIsImportingWorkbook(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex bg-background">
|
||||
<AppSidebar collapsed={sidebarCollapsed} onToggle={() => setSidebarCollapsed((value) => !value)} />
|
||||
|
||||
<main className="flex-1 flex flex-col min-w-0 overflow-hidden">
|
||||
<header className="bg-card border-b border-border px-6 py-4 flex-shrink-0">
|
||||
<div className="flex 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 flex-shrink-0">
|
||||
<Banknote className="w-5 h-5 text-emerald-600" />
|
||||
</div>
|
||||
<div className="w-10 h-10 rounded-xl bg-emerald-500/15 flex items-center justify-center flex-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' }}>
|
||||
Masse salariale — SANTINOVA
|
||||
</h1>
|
||||
<p className="text-sm text-muted-foreground mt-0.5">
|
||||
Rémunérations 2025–2027 et évolutions salariales manuelles
|
||||
</p>
|
||||
<h1 className="text-xl font-bold text-foreground" style={{ fontFamily: "Sora, sans-serif" }}>Masse salariale — SANTINOVA</h1>
|
||||
<p className="text-sm text-muted-foreground mt-0.5">Prévisionnel et réel annuel, issus du fichier de référence et des liasses archivées.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="hidden sm: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 className="hidden sm: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">
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
|
||||
<section className="bg-card border border-border rounded-xl shadow-sm px-5 py-4 flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div className="flex items-start gap-3"><div className="w-9 h-9 rounded-lg bg-emerald-500/15 flex items-center justify-center flex-shrink-0"><FileSpreadsheet className="w-4 h-4 text-emerald-600" /></div><div><h2 className="font-semibold text-foreground">Fichier prévisionnel de référence</h2><p className="text-xs text-muted-foreground mt-0.5">Administrateur uniquement. L'import remplace les prévisions 2025–2027, sans modifier les liasses de paie ni les réels déjà archivés.</p></div></div>
|
||||
<div className="flex flex-col sm:flex-row gap-2 sm:items-center"><input aria-label="Fichier Excel de masse salariale" type="file" accept=".xlsx,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" onChange={(event) => setWorkbookFile(event.target.files?.[0] ?? null)} disabled={isImportingWorkbook} className="max-w-72 text-xs file:mr-3 file:rounded-md file:border-0 file:bg-emerald-50 file:px-3 file:py-2 file:text-xs file:font-semibold file:text-emerald-700 hover:file:bg-emerald-100" /><button onClick={importWorkbook} disabled={!workbookFile || isImportingWorkbook} className="inline-flex items-center justify-center gap-2 rounded-lg bg-emerald-600 px-3 py-2 text-xs font-semibold text-white hover:bg-emerald-700 disabled:cursor-not-allowed disabled:opacity-50"><Upload className="w-3.5 h-3.5" />{isImportingWorkbook ? "Import en cours…" : "Importer le fichier"}</button></div>
|
||||
</section>
|
||||
|
||||
<div className="grid grid-cols-1 xl:grid-cols-3 gap-3">
|
||||
{ANNEES.map((annee) => {
|
||||
const total = totalsByYear.get(annee);
|
||||
const isProvisional = total?.status === 'cumul_provisoire';
|
||||
return (
|
||||
<section key={annee} className="bg-card border border-border rounded-xl shadow-sm p-4">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2 text-sm font-semibold text-foreground">
|
||||
<CalendarDays className="w-4 h-4 text-emerald-600" />
|
||||
{annee}
|
||||
</div>
|
||||
<span className={`text-[10px] font-semibold uppercase tracking-wide px-2 py-0.5 rounded-full ${
|
||||
annee === 2025
|
||||
? 'bg-emerald-100 text-emerald-700'
|
||||
: isProvisional
|
||||
? 'bg-amber-100 text-amber-700'
|
||||
: 'bg-muted text-muted-foreground'
|
||||
}`}>
|
||||
{total?.status === 'annuel' ? 'Année clôturée' : isProvisional ? 'Cumul au dernier bulletin' : 'À renseigner'}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-3 text-2xl font-bold text-foreground">
|
||||
{formatEuros(total?.annualGrossWithBonusesCents)}
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
Brut avec primes — {total?.salaryCount ?? 0} salarié{(total?.salaryCount ?? 0) > 1 ? 's' : ''}
|
||||
</p>
|
||||
</section>
|
||||
);
|
||||
const total = totals.get(annee);
|
||||
const hasActual = (total?.actualCount ?? 0) > 0;
|
||||
return <section key={annee} className="bg-card border border-border rounded-xl shadow-sm p-4">
|
||||
<div className="flex items-center gap-2 text-sm font-semibold text-foreground"><CalendarDays className="w-4 h-4 text-emerald-600" />{annee}</div>
|
||||
<div className="grid grid-cols-2 gap-4 mt-3">
|
||||
<div><p className="text-[10px] font-semibold uppercase tracking-wide text-emerald-700">Prévisionnel</p><p className="text-xl font-bold text-foreground mt-1">{formatEuros(total?.forecast)}</p><p className="text-xs text-muted-foreground mt-1">{total?.forecastCount ?? 0} salarié(s)</p></div>
|
||||
<div className="border-l border-border pl-4"><p className="text-[10px] font-semibold uppercase tracking-wide text-sky-700">Réel</p><p className="text-xl font-bold text-foreground mt-1">{hasActual ? formatEuros(total?.actual) : "—"}</p><p className="text-xs text-muted-foreground mt-1">{hasActual ? `${total?.actualCount} salarié(s) · ${formatMonths(total?.actualMonths ? Array.from({ length: total.actualMonths }, (_, index) => index) : [])}` : "Aucune liasse"}</p></div>
|
||||
</div>
|
||||
</section>;
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="bg-amber-50 border border-amber-200 rounded-xl px-4 py-3 flex gap-3 text-sm text-amber-900">
|
||||
<CircleAlert className="w-4 h-4 flex-shrink-0 mt-0.5 text-amber-600" />
|
||||
<p>
|
||||
<strong>2025</strong> est issu des cumuls annuels de décembre. <strong>2026</strong> est un cumul provisoire au dernier bulletin disponible pour chaque salarié (janvier à août). Aucun salaire 2027 n’est calculé automatiquement ; seules les évolutions peuvent être saisies.
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-amber-50 border border-amber-200 rounded-xl px-4 py-3 flex gap-3 text-sm text-amber-900"><CircleAlert className="w-4 h-4 flex-shrink-0 mt-0.5 text-amber-600" /><p><strong>Réel 2026 :</strong> cumul strict des liasses mensuelles disponibles, sans annualisation. Le brut hors astreinte retire uniquement les primes d’astreinte. Les cellules vertes <strong>Prime</strong> et <strong>Évolution</strong> sont modifiables seulement pour {data?.currentYear ?? "l’exercice courant"} et les années suivantes.</p></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">Détail par salarié</h2>
|
||||
<p className="text-xs text-muted-foreground">Cliquez sur une évolution pour la saisir ou la modifier.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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">Détail par salarié</h2><p className="text-xs text-muted-foreground">Prévisionnel issu du fichier modèle ; réel issu des clôtures et liasses PDF archivées.</p></div></div>
|
||||
<MasseSalarialeDataState state={uiState}>
|
||||
<div className="overflow-auto max-h-[calc(100vh-335px)]">
|
||||
<table className="w-full min-w-[1900px] text-sm border-collapse">
|
||||
<div className="overflow-auto max-h-[calc(100vh-350px)]">
|
||||
<table className="w-full min-w-[4900px] text-sm border-collapse">
|
||||
<thead className="sticky top-0 z-10 bg-muted/95 backdrop-blur">
|
||||
<tr className="border-b border-border">
|
||||
<th rowSpan={2} className="sticky left-0 z-20 bg-muted/95 text-left px-4 py-3 font-semibold text-foreground">Salarié</th>
|
||||
<th rowSpan={2} className="text-left px-4 py-3 font-semibold text-foreground">Poste</th>
|
||||
<th rowSpan={2} className="text-left px-4 py-3 font-semibold text-foreground">Embauche</th>
|
||||
{ANNEES.map((annee) => <th key={annee} colSpan={5} className="text-center px-3 py-2 font-bold text-foreground border-l border-border">{annee}</th>)}
|
||||
</tr>
|
||||
<tr className="border-b border-border text-[10px] uppercase tracking-wide text-muted-foreground">
|
||||
{ANNEES.flatMap((annee) => [
|
||||
<th key={`${annee}-annual-base`} className="px-3 py-2 text-right font-medium border-l border-border">Annuel hors primes</th>,
|
||||
<th key={`${annee}-annual-total`} className="px-3 py-2 text-right font-medium">Annuel avec primes</th>,
|
||||
<th key={`${annee}-monthly`} className="px-3 py-2 text-right font-medium">Mensuel hors primes</th>,
|
||||
<th key={`${annee}-charges`} className="px-3 py-2 text-right font-medium">Taux charges</th>,
|
||||
<th key={`${annee}-evolution`} className="px-3 py-2 text-right font-medium">Évolution</th>,
|
||||
])}
|
||||
</tr>
|
||||
<tr className="border-b border-border"><th rowSpan={3} className="sticky left-0 z-20 bg-muted/95 text-left px-4 py-3 font-semibold text-foreground">Salarié</th><th rowSpan={3} className="text-left px-4 py-3 font-semibold text-foreground">Poste</th><th rowSpan={3} className="text-left px-4 py-3 font-semibold text-foreground">Embauche</th>{ANNEES.map((annee) => <th key={annee} colSpan={12} className="text-center px-3 py-2 font-bold text-foreground border-l border-border">{annee}</th>)}</tr>
|
||||
<tr className="border-b border-border text-[10px] uppercase tracking-wide"><th colSpan={7} className="px-3 py-2 text-center text-emerald-700 border-l border-border">Prévisionnel</th><th colSpan={5} className="px-3 py-2 text-center text-sky-700 border-l border-border">Réel</th>{ANNEES.slice(1).flatMap((annee) => [<th key={`${annee}-p`} colSpan={7} className="px-3 py-2 text-center text-emerald-700 border-l border-border">Prévisionnel</th>, <th key={`${annee}-r`} colSpan={5} className="px-3 py-2 text-center text-sky-700 border-l border-border">Réel</th>])}</tr>
|
||||
<tr className="border-b border-border text-[10px] uppercase tracking-wide text-muted-foreground">{ANNEES.flatMap((annee) => [
|
||||
<th key={`${annee}-f-base`} className="px-3 py-2 text-right font-medium border-l border-border">Annuel hors astreinte</th>,
|
||||
<th key={`${annee}-f-total`} className="px-3 py-2 text-right font-medium">Annuel avec primes</th>,
|
||||
<th key={`${annee}-f-monthly`} className="px-3 py-2 text-right font-medium">Mensuel hors astreinte</th>,
|
||||
<th key={`${annee}-f-astreinte`} className="px-3 py-2 text-right font-medium">Astreintes</th>,
|
||||
<th key={`${annee}-f-charges`} className="px-3 py-2 text-right font-medium">Taux charges</th>,
|
||||
<th key={`${annee}-f-evolution`} className="px-3 py-2 text-right font-medium">Évolution</th>,
|
||||
<th key={`${annee}-f-prime`} className="px-3 py-2 text-right font-medium">Prime</th>,
|
||||
<th key={`${annee}-a-base`} className="px-3 py-2 text-right font-medium border-l border-border">Cumul hors astreinte</th>,
|
||||
<th key={`${annee}-a-total`} className="px-3 py-2 text-right font-medium">Cumul avec primes</th>,
|
||||
<th key={`${annee}-a-monthly`} className="px-3 py-2 text-right font-medium">Moy. mensuelle</th>,
|
||||
<th key={`${annee}-a-astreinte`} className="px-3 py-2 text-right font-medium">Astreintes</th>,
|
||||
<th key={`${annee}-a-months`} className="px-3 py-2 text-right font-medium">Mois</th>,
|
||||
])}</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data?.salaries.map((salarie) => (
|
||||
<tr key={salarie.id} className="border-b border-border/70 hover:bg-muted/30">
|
||||
<td className="sticky left-0 z-[1] bg-card px-4 py-3 min-w-[220px]">
|
||||
<p className="font-semibold text-foreground">{salarie.nom} {salarie.prenom}</p>
|
||||
<p className="text-[11px] text-muted-foreground font-mono">{salarie.matricule}</p>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-xs text-foreground min-w-[200px]">{salarie.poste}</td>
|
||||
<td className="px-4 py-3 text-xs text-muted-foreground whitespace-nowrap">{formatDate(salarie.dateEmbauche)}</td>
|
||||
{ANNEES.map((annee) => {
|
||||
const remuneration = remunerationByKey.get(evolutionKey(salarie.id, annee));
|
||||
const evolution = evolutionByKey.get(evolutionKey(salarie.id, annee));
|
||||
const key = evolutionKey(salarie.id, annee);
|
||||
return (
|
||||
<Fragment key={key}>
|
||||
<td key={`${key}-annual-base`} className="px-3 py-3 text-right font-medium text-foreground border-l border-border">{formatEuros(remuneration?.salaireAnnuelBrutHorsPrimesCents)}</td>
|
||||
<td key={`${key}-annual-total`} className="px-3 py-3 text-right font-medium text-foreground">{formatEuros(remuneration?.salaireAnnuelBrutAvecPrimesCents)}</td>
|
||||
<td key={`${key}-monthly`} className="px-3 py-3 text-right text-foreground">{formatEuros(remuneration?.salaireMensuelBrutHorsPrimesCents)}</td>
|
||||
<td key={`${key}-charges`} className="px-3 py-3 text-right text-foreground">{formatPercent(remuneration?.tauxChargeBps)}</td>
|
||||
<td key={`${key}-evolution`} className="px-3 py-2 text-right">
|
||||
{editingKey === key ? (
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
<input
|
||||
aria-label={`Évolution ${annee} de ${salarie.nom}`}
|
||||
type="number"
|
||||
min="-100"
|
||||
max="100"
|
||||
step="0.01"
|
||||
value={evolutionDraft}
|
||||
onChange={(event) => setEvolutionDraft(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter') saveEvolution(salarie.id, annee);
|
||||
if (event.key === 'Escape') setEditingKey(null);
|
||||
}}
|
||||
className="w-20 rounded border border-emerald-400 bg-white px-2 py-1 text-right text-xs focus:outline-none focus:ring-2 focus:ring-emerald-300"
|
||||
autoFocus
|
||||
disabled={setEvolution.isPending}
|
||||
/>
|
||||
<button
|
||||
onClick={() => saveEvolution(salarie.id, annee)}
|
||||
className="text-[11px] font-semibold text-emerald-700 hover:text-emerald-900 disabled:opacity-50"
|
||||
disabled={setEvolution.isPending}
|
||||
>
|
||||
OK
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => startEvolutionEdit(salarie.id, annee)}
|
||||
className="min-w-20 rounded px-2 py-1 text-xs font-semibold text-emerald-700 hover:bg-emerald-50 focus:outline-none focus:ring-2 focus:ring-emerald-300"
|
||||
title="Saisir l'évolution salariale"
|
||||
>
|
||||
{formatPercent(evolution?.evolutionSalarialeBps)}
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
<tbody>{data?.salaries.map((salarie) => <tr key={salarie.id} className="border-b border-border/70 hover:bg-muted/30"><td className="sticky left-0 z-[1] bg-card px-4 py-3 min-w-[220px]"><p className="font-semibold text-foreground">{salarie.nom} {salarie.prenom}</p><p className="text-[11px] text-muted-foreground font-mono">{salarie.matricule}</p></td><td className="px-4 py-3 text-xs text-foreground min-w-[200px]">{salarie.poste}</td><td className="px-4 py-3 text-xs text-muted-foreground whitespace-nowrap">{formatDate(salarie.dateEmbauche)}</td>{ANNEES.map((annee) => {
|
||||
const forecast = forecastByKey.get(keyFor(salarie.id, annee));
|
||||
const actual = actualByKey.get(keyFor(salarie.id, annee));
|
||||
const editing = editingKey === keyFor(salarie.id, annee);
|
||||
return <Fragment key={keyFor(salarie.id, annee)}>
|
||||
<td className="px-3 py-3 text-right font-medium text-foreground border-l border-border">{formatEuros(forecast?.brutHorsAstreinteCents)}</td><td className="px-3 py-3 text-right font-medium text-foreground">{formatEuros(forecast?.brutAvecPrimesCents)}</td><td className="px-3 py-3 text-right text-foreground">{formatEuros(forecast?.brutMensuelHorsAstreinteCents)}</td><td className="px-3 py-3 text-right text-foreground">{formatEuros(forecast?.primeAstreinteCents)}</td><td className="px-3 py-3 text-right text-foreground">{formatPercent(forecast?.tauxChargeBps)}</td>
|
||||
<td className="px-3 py-2 text-right">{editing ? <input aria-label={`Évolution ${annee} de ${salarie.nom}`} type="number" step="0.01" value={evolutionDraft} onChange={(event) => setEvolutionDraft(event.target.value)} className="w-24 rounded border border-emerald-400 bg-white px-2 py-1 text-right text-xs focus:outline-none focus:ring-2 focus:ring-emerald-300" disabled={saveAdjustments.isPending} /> : forecast?.editable ? <button onClick={() => startEdit(salarie.id, annee)} className="min-w-20 rounded px-2 py-1 text-xs font-semibold text-emerald-700 hover:bg-emerald-50 focus:outline-none focus:ring-2 focus:ring-emerald-300">{formatEuros(forecast.evolutionCents)}</button> : <span className="inline-flex items-center gap-1 text-xs text-muted-foreground"><LockKeyhole className="w-3 h-3" />{formatEuros(forecast?.evolutionCents)}</span>}</td>
|
||||
<td className="px-3 py-2 text-right">{editing ? <div className="flex items-center justify-end gap-1"><input aria-label={`Prime ${annee} de ${salarie.nom}`} type="number" step="0.01" value={primeDraft} onChange={(event) => setPrimeDraft(event.target.value)} className="w-24 rounded border border-emerald-400 bg-white px-2 py-1 text-right text-xs focus:outline-none focus:ring-2 focus:ring-emerald-300" disabled={saveAdjustments.isPending} /><button onClick={() => saveEdit(salarie.id, annee)} className="text-[11px] font-semibold text-emerald-700 hover:text-emerald-900" disabled={saveAdjustments.isPending}>OK</button></div> : forecast?.editable ? <button onClick={() => startEdit(salarie.id, annee)} className="min-w-20 rounded px-2 py-1 text-xs font-semibold text-emerald-700 hover:bg-emerald-50 focus:outline-none focus:ring-2 focus:ring-emerald-300">{formatEuros(forecast.primeCents)}</button> : <span className="inline-flex items-center gap-1 text-xs text-muted-foreground"><LockKeyhole className="w-3 h-3" />{formatEuros(forecast?.primeCents)}</span>}</td>
|
||||
<td className="px-3 py-3 text-right font-medium text-foreground border-l border-border">{formatEuros(actual?.brutHorsAstreinteCents)}</td><td className="px-3 py-3 text-right font-medium text-foreground">{formatEuros(actual?.brutAvecPrimesCents)}</td><td className="px-3 py-3 text-right text-foreground">{formatEuros(actual?.brutMensuelHorsAstreinteCents)}</td><td className="px-3 py-3 text-right text-foreground">{formatEuros(actual?.primeAstreinteCents)}</td><td className="px-3 py-3 text-right text-muted-foreground">{formatMonths(actual?.moisDisponibles)}</td>
|
||||
</Fragment>;
|
||||
})}</tr>)}</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</MasseSalarialeDataState>
|
||||
|
||||
18
client/src/pages/Salaires.test.tsx
Normal file
18
client/src/pages/Salaires.test.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { PayrollSecurityNotice } from "./Salaires";
|
||||
|
||||
describe("PayrollSecurityNotice", () => {
|
||||
it("affiche un état neutre de vérification, et non une alerte, tant que la sonde de chiffrement charge", () => {
|
||||
const html = renderToStaticMarkup(<PayrollSecurityNotice isLoading ready={undefined} />);
|
||||
|
||||
expect(html).toContain("Vérification du chiffrement sécurisé…");
|
||||
expect(html).not.toContain("Import bloqué");
|
||||
});
|
||||
|
||||
it("n'affiche l'alerte de blocage que lorsque la sonde a répondu indisponible", () => {
|
||||
const html = renderToStaticMarkup(<PayrollSecurityNotice isLoading={false} ready={false} />);
|
||||
|
||||
expect(html).toContain("Import bloqué");
|
||||
});
|
||||
});
|
||||
229
client/src/pages/Salaires.tsx
Normal file
229
client/src/pages/Salaires.tsx
Normal file
@@ -0,0 +1,229 @@
|
||||
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, isLoading: isSecurityLoading } = 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 !== true) {
|
||||
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">
|
||||
<PayrollSecurityNotice isLoading={isSecurityLoading} ready={security?.ready} />
|
||||
|
||||
<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 || isSecurityLoading || hasExistingPeriod || !selectedFile || security?.ready !== true} 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>;
|
||||
}
|
||||
|
||||
export function PayrollSecurityNotice({ isLoading, ready }: { isLoading: boolean; ready: boolean | undefined }) {
|
||||
if (isLoading) {
|
||||
return <section className="rounded-xl border border-sky-200 bg-sky-50 px-4 py-3 flex gap-3 text-sm text-sky-900"><Loader2 className="w-4 h-4 mt-0.5 shrink-0 animate-spin text-sky-700" /><p><strong>Vérification du chiffrement sécurisé…</strong> L'import reste temporairement désactivé.</p></section>;
|
||||
}
|
||||
if (ready === true) return null;
|
||||
return <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>;
|
||||
}
|
||||
@@ -4,31 +4,35 @@ services:
|
||||
container_name: itinova-budget-si-app
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
- DATABASE_URL=mysql://budget_user:BudgetSI2027!@db:3306/budget_si
|
||||
- JWT_SECRET=${JWT_SECRET:-budget-si-recette-jwt-secret-2027}
|
||||
- VITE_APP_ID=${VITE_APP_ID:-}
|
||||
- OAUTH_SERVER_URL=${OAUTH_SERVER_URL:-}
|
||||
- VITE_OAUTH_PORTAL_URL=${VITE_OAUTH_PORTAL_URL:-}
|
||||
- OWNER_OPEN_ID=${OWNER_OPEN_ID:-}
|
||||
- OWNER_NAME=${OWNER_NAME:-Administrateur}
|
||||
- BUILT_IN_FORGE_API_URL=${BUILT_IN_FORGE_API_URL:-}
|
||||
- BUILT_IN_FORGE_API_KEY=${BUILT_IN_FORGE_API_KEY:-}
|
||||
- VITE_FRONTEND_FORGE_API_KEY=${VITE_FRONTEND_FORGE_API_KEY:-}
|
||||
- VITE_FRONTEND_FORGE_API_URL=${VITE_FRONTEND_FORGE_API_URL:-}
|
||||
- VITE_ANALYTICS_ENDPOINT=${VITE_ANALYTICS_ENDPOINT:-}
|
||||
- VITE_ANALYTICS_WEBSITE_ID=${VITE_ANALYTICS_WEBSITE_ID:-}
|
||||
- NODE_ENV=production
|
||||
- DATABASE_URL=mysql://budget_user:BudgetSI2027!@itinova-budget-si-db:3306/budget_si
|
||||
- JWT_SECRET=${JWT_SECRET:-budget-si-recette-jwt-secret-2027}
|
||||
- VITE_APP_ID=${VITE_APP_ID:-}
|
||||
- OAUTH_SERVER_URL=${OAUTH_SERVER_URL:-}
|
||||
- VITE_OAUTH_PORTAL_URL=${VITE_OAUTH_PORTAL_URL:-}
|
||||
- OWNER_OPEN_ID=${OWNER_OPEN_ID:-}
|
||||
- OWNER_NAME=${OWNER_NAME:-Administrateur}
|
||||
- BUILT_IN_FORGE_API_URL=${BUILT_IN_FORGE_API_URL:-}
|
||||
- BUILT_IN_FORGE_API_KEY=${BUILT_IN_FORGE_API_KEY:-}
|
||||
- VITE_FRONTEND_FORGE_API_KEY=${VITE_FRONTEND_FORGE_API_KEY:-}
|
||||
- VITE_FRONTEND_FORGE_API_URL=${VITE_FRONTEND_FORGE_API_URL:-}
|
||||
- VITE_ANALYTICS_ENDPOINT=${VITE_ANALYTICS_ENDPOINT:-}
|
||||
- VITE_ANALYTICS_WEBSITE_ID=${VITE_ANALYTICS_WEBSITE_ID:-}
|
||||
- PAYROLL_ENCRYPTION_KEY=${PAYROLL_ENCRYPTION_KEY:-}
|
||||
- PAYROLL_STORAGE_DIR=/var/lib/itinova-budget-si/payroll
|
||||
volumes:
|
||||
- ${PAYROLL_STORAGE_HOST_DIR:-/opt/manus-deploy/data/itinova-budget-si/payroll}:/var/lib/itinova-budget-si/payroll
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
networks:
|
||||
- web
|
||||
- web
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.http.routers.budget-si.rule=Host(`budget-si.recette.santinova-soft.org`)"
|
||||
- "traefik.http.routers.budget-si.entrypoints=websecure"
|
||||
- "traefik.http.routers.budget-si.tls.certresolver=letsencrypt"
|
||||
- "traefik.http.services.budget-si.loadbalancer.server.port=3000"
|
||||
- "traefik.enable=true"
|
||||
- "traefik.http.routers.budget-si.rule=Host(`${BUDGET_SI_DOMAIN:-budget-si.recette.santinova-soft.org}`)"
|
||||
- "traefik.http.routers.budget-si.entrypoints=websecure"
|
||||
- "traefik.http.routers.budget-si.tls.certresolver=letsencrypt"
|
||||
- "traefik.http.services.budget-si.loadbalancer.server.port=3000"
|
||||
|
||||
db:
|
||||
image: mysql:8.0
|
||||
@@ -40,14 +44,25 @@ services:
|
||||
MYSQL_USER: budget_user
|
||||
MYSQL_PASSWORD: BudgetSI2027!
|
||||
volumes:
|
||||
- db_data:/var/lib/mysql
|
||||
- db_data:/var/lib/mysql
|
||||
healthcheck:
|
||||
test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "root", "-pBudgetSI2027!"]
|
||||
test:
|
||||
[
|
||||
"CMD",
|
||||
"mysqladmin",
|
||||
"ping",
|
||||
"-h",
|
||||
"localhost",
|
||||
"-u",
|
||||
"root",
|
||||
"-pBudgetSI2027!",
|
||||
]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
networks:
|
||||
- web
|
||||
- web
|
||||
|
||||
volumes:
|
||||
db_data:
|
||||
|
||||
|
||||
37
docs/masse-salariale-previsionnel-reel.md
Normal file
37
docs/masse-salariale-previsionnel-reel.md
Normal file
@@ -0,0 +1,37 @@
|
||||
# Masse salariale SANTINOVA — Prévisionnel et réel
|
||||
|
||||
## Source et périmètre
|
||||
|
||||
Le fichier `TableaurecapMassesalarialeSANTINOVA-28.08.26.xlsx`, fourni le 31 août 2026, sert de référence pour les prévisionnels 2025, 2026 et 2027. Il comporte une feuille **Masse salariale** et une feuille **Méthode**. Le détail reste exclusivement accessible aux administrateurs.
|
||||
|
||||
## Règles de restitution
|
||||
|
||||
| Année | Prévisionnel | Réel |
|
||||
|---|---|---|
|
||||
| 2025 | Valeurs du fichier | Mêmes valeurs que le prévisionnel, année clôturée |
|
||||
| 2026 | Valeurs du fichier | Cumul des seules liasses mensuelles archivées dans Salaires, sans extrapolation |
|
||||
| 2027 | Valeurs du fichier | Non disponible tant qu'aucune liasse mensuelle n'est importée |
|
||||
|
||||
Le brut hors astreinte correspond au brut avec primes diminué **uniquement** des primes d'astreinte. Les autres éléments inclus au brut, notamment les primes d'activité, restent dans le montant.
|
||||
|
||||
## Édition et calcul
|
||||
|
||||
Les valeurs de référence importées sont conservées. Pour l'année courante et les années futures, un administrateur peut modifier seulement les colonnes **Prime** et **Évolution** du prévisionnel. Les totaux prévisionnels sont recalculés à partir de la référence, en ajoutant ou retirant l'écart entre la valeur modifiée et la valeur initiale. Les exercices antérieurs sont verrouillés.
|
||||
|
||||
Le réel 2026 affiche le nombre de mois effectivement disponibles et ne transforme jamais un cumul partiel en prévision annuelle.
|
||||
|
||||
La vérification visuelle de développement a confirmé les trois années, les blocs prévisionnel-réel, le cumul réel 2026 limité aux deux liasses disponibles, et la présence des colonnes éditables uniquement sur les exercices autorisés. Les montants doivent néanmoins conserver leurs centimes à l'affichage afin d'éviter tout arrondi de restitution.
|
||||
|
||||
Après correction, l'interface restitue les centimes issus du fichier modèle. La table détaille, pour chacune des années 2025, 2026 et 2027, les colonnes prévisionnelles et réelles séparées. Les valeurs réelles non disponibles restent indiquées comme absentes plutôt que remplacées par une estimation.
|
||||
|
||||
La liasse de janvier 2026 a été archivée depuis la fenêtre Salaires, chiffrée avant stockage et indexée complètement. Elle alimente désormais le cumul réel 2026 sans réécriture d'une valeur prévisionnelle.
|
||||
|
||||
La liasse de février 2026 a également été archivée et indexée. Le comparatif M-1 de Salaires est disponible à partir de février ; les variations restent reliées aux rubriques extraites et non à une estimation annuelle.
|
||||
|
||||
La liasse de mars 2026 est archivée avec un statut partiel : les fiches non reconnues restent signalées et le PDF conservé, tandis que le cumul réel utilise uniquement les bulletins effectivement indexés.
|
||||
|
||||
La liasse d'avril 2026 est également archivée avec un statut partiel. Le réel annuel conserve donc explicitement sa couverture mensuelle et ne masque pas les extractions incomplètes.
|
||||
|
||||
Les contrôles automatisés finaux ont réussi : 72 tests couvrant notamment l'accès administrateur, les projections, l'import du modèle, la confidentialité des bulletins et les règles de persistance. Le contrôle TypeScript et le build de production ont également réussi.
|
||||
|
||||
Le téléversement administrateur du fichier modèle a été validé de bout en bout. Le serveur lit le classeur, remplace de manière atomique les seules prévisions 2025–2027 et renvoie uniquement un décompte ; les lignes salariales ne quittent pas l'environnement serveur dans la réponse d'import.
|
||||
67
docs/salaires-securite.md
Normal file
67
docs/salaires-securite.md
Normal file
@@ -0,0 +1,67 @@
|
||||
# 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 stockage. Lorsque Forge est configuré, l'archive chiffrée est envoyée au stockage persistant du projet. En recette, où Forge n'est pas disponible, elle est stockée dans un volume Docker monté depuis `/opt/manus-deploy/data/itinova-budget-si/payroll` sur l'hôte : ce volume n'est ni intégré à l'image, ni supprimé par la reconstruction du conteneur. 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.
|
||||
|
||||
La CI Gitea de recette et le déploiement Blue-Green ont été validés pour le commit qui transmet explicitement la clé de paie au conteneur applicatif. Le déploiement sauvegarde la base avant reconstruction, met à jour le code depuis Gitea, reconstruit l'image sans cache, puis recrée l'application une fois la base saine.
|
||||
|
||||
Après ce déploiement, une vérification de recette a créé un marqueur de contrôle dans le volume de paie, recréé uniquement le conteneur applicatif, puis relu le marqueur avec la même empreinte SHA-256. Le marqueur a été supprimé immédiatement après le contrôle. Cette vérification confirme la persistance effective du volume à travers une recréation Docker, sans y déposer de donnée salariale de test.
|
||||
|
||||
Le contrôle final de recette a ensuite importé une liasse réelle de mai 2026. L'import a créé une archive chiffrée et un index de 18 bulletins au statut `partiel`, sans fabrication des quatre fiches non reconnues. La liste tRPC filtrée retourne les 18 entrées et n'expose ni clé de stockage, ni IV, ni tag d'authentification, ni empreinte. La route PDF a retourné un document de 79 752 octets commençant par `%PDF-`, avec les en-têtes `Content-Type: application/pdf`, `Cache-Control: no-store, private` et `X-Content-Type-Options: nosniff`. Après une recréation Docker de l'application, la même archive était encore lisible, avec la même taille et la même signature PDF.
|
||||
|
||||
La vérification visuelle administrateur de recette a confirmé la présence de l'entrée **Salaires**, des filtres, de l'archive de mai et de son statut partiel. Une alerte frontale incohérente indiquant que le chiffrement était indisponible a toutefois été observée alors que la sonde serveur retournait bien `AES-256-GCM` opérationnel ; cette divergence doit être corrigée avant clôture.
|
||||
|
||||
Après le chargement des requêtes, le filtre de mai 2026 a affiché 18 bulletins indexés, un total brut mensuel et le statut partiel de la liasse, sans alerte de chiffrement. La divergence initiale correspond au rendu transitoire avant réception de la sonde de sécurité ; ce rendu doit être remplacé par un état de chargement neutre, jamais par une alerte de blocage.
|
||||
|
||||
Depuis la page authentifiée de recette, le bouton **Voir la liasse** a ouvert le visualiseur PDF du navigateur sur la route protégée de consultation. Le parcours interface utilise uniquement cette route applicative ; il ne présente aucune URL de stockage au navigateur.
|
||||
|
||||
Un diagnostic de recette a révélé qu'après une recréation Docker, l'alias réseau générique `db` pouvait résoudre vers une autre base présente sur le réseau partagé, provoquant un refus d'accès et empêchant temporairement toute connexion. La configuration utilise désormais le nom unique `itinova-budget-si-db`. Une sonde de régression vérifie ce point avant chaque déploiement.
|
||||
|
||||
La CI Gitea `validate.yml` associée au correctif de résolution MySQL a terminé avec succès. Le contrôle de déploiement final reste centré sur la reconnexion administrateur et la page Salaires en HTTPS après le redémarrage applicatif.
|
||||
|
||||
Après le déploiement du correctif, la connexion avec le compte administrateur de recette a de nouveau abouti et l'interface générale s'affiche sans erreur de base de données.
|
||||
|
||||
La validation finale en HTTPS a confirmé le menu **Santinova > Salaires**, l'absence de fausse alerte de chiffrement, l'affichage de la liasse archivée et l'ouverture de cette liasse exclusivement via le bouton de consultation protégé. Aucun lien de stockage n'est exposé dans l'interface.
|
||||
|
||||
Le portail de recette et le dashboard ont été synchronisés à partir du manifeste applicatif. La carte **Gestion budget informatique** est présente dans l'onglet Santinova du portail, utilise son visuel dédié et ouvre l'URL HTTPS de recette.
|
||||
|
||||
La promotion vers la production utilise le domaine HTTPS `budget-si.santinova-soft.org`, un secret de session et une clé de chiffrement distincts de la recette, ainsi qu'un volume persistant de paie dédié. La page de connexion de production a répondu après le déploiement initial.
|
||||
|
||||
Le dashboard de production a découvert Budget SI et le signale en ligne. Le portail de production présente désormais la carte **Gestion budget informatique** dans l'onglet Santinova, avec son visuel dédié et le lien HTTPS de production. La promotion a été réalisée depuis le dépôt Gitea production par le flux Santinova de déploiement sécurisé ; la CI Gitea reste validée sur la recette avant promotion.
|
||||
|
||||
La validation de connexion administrateur de production a mis en évidence une initialisation de schéma incomplète : la table des utilisateurs n'est pas encore disponible. Cette correction est requise avant de déclarer la production utilisable.
|
||||
|
||||
Le serveur de production a été redéployé depuis le dépôt Gitea après ajout du bootstrap de migrations et du compte administrateur initial. La page HTTPS de connexion est de nouveau accessible ; la validation authentifiée suit.
|
||||
|
||||
La connexion avec le compte administrateur initial a abouti après le redéploiement. Le menu d'administration et les modules Budget SI sont accessibles en production sur une base désormais initialisée.
|
||||
|
||||
Le parcours administrateur de production permet d'ouvrir **Santinova > Salaires**. La page présente les filtres mensuels, l'import PDF limité et les zones d'archives ; l'état du chiffrement est encore en vérification au premier affichage et fait l'objet du contrôle final.
|
||||
|
||||
La page HTTPS de connexion de recette est accessible après la synchronisation du bootstrap ; la validation administrateur de recette reste en cours.
|
||||
|
||||
La tentative de connexion administrateur de recette est restée en attente après le redéploiement. Le diagnostic de cette divergence, alors que la production est fonctionnelle, est en cours avant clôture.
|
||||
|
||||
Après redémarrage contrôlé du portail et du dashboard, puis relance du déploiement une fois la CI de recette terminée avec succès, la connexion administrateur de recette est de nouveau opérationnelle.
|
||||
|
||||
La validation finale de recette confirme l'accès administrateur à **Santinova > Salaires**, le statut de chiffrement opérationnel, l'archive mensuelle existante, son statut partiel explicite, ainsi que les commandes de réindexation et de consultation protégée.
|
||||
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;
|
||||
9
drizzle/0011_dashing_grey_gargoyle.sql
Normal file
9
drizzle/0011_dashing_grey_gargoyle.sql
Normal file
@@ -0,0 +1,9 @@
|
||||
ALTER TABLE `masse_salariale_remunerations` DROP INDEX `masse_salariale_salarie_annee_idx`;--> statement-breakpoint
|
||||
ALTER TABLE `masse_salariale_remunerations` MODIFY COLUMN `tauxChargeBps` int;--> statement-breakpoint
|
||||
ALTER TABLE `masse_salariale_remunerations` ADD `nature` enum('previsionnel') DEFAULT 'previsionnel' NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE `masse_salariale_remunerations` ADD `primeAstreinteCents` int DEFAULT 0 NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE `masse_salariale_remunerations` ADD `primeCents` int DEFAULT 0 NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE `masse_salariale_remunerations` ADD `evolutionCents` int DEFAULT 0 NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE `masse_salariale_remunerations` ADD `primeReferenceCents` int DEFAULT 0 NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE `masse_salariale_remunerations` ADD `evolutionReferenceCents` int DEFAULT 0 NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE `masse_salariale_remunerations` ADD CONSTRAINT `masse_salariale_salarie_annee_nature_idx` UNIQUE(`salarieId`,`annee`,`nature`);
|
||||
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
1521
drizzle/meta/0011_snapshot.json
Normal file
1521
drizzle/meta/0011_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -57,6 +57,34 @@
|
||||
"when": 1787833910943,
|
||||
"tag": "0007_rich_tony_stark",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 8,
|
||||
"version": "5",
|
||||
"when": 1788040817404,
|
||||
"tag": "0008_living_silver_surfer",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 9,
|
||||
"version": "5",
|
||||
"when": 1788041511204,
|
||||
"tag": "0009_white_stardust",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 10,
|
||||
"version": "5",
|
||||
"when": 1788041740554,
|
||||
"tag": "0010_brainy_oracle",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 11,
|
||||
"version": "5",
|
||||
"when": 1788172234921,
|
||||
"tag": "0011_dashing_grey_gargoyle",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -230,10 +230,19 @@ export const masseSalarialeRemunerations = mysqlTable("masse_salariale_remunerat
|
||||
id: int("id").autoincrement().primaryKey(),
|
||||
salarieId: int("salarieId").notNull(),
|
||||
annee: int("annee").notNull(),
|
||||
/** Les lignes stockées constituent le prévisionnel ; le réel est dérivé des liasses mensuelles. */
|
||||
nature: mysqlEnum("nature", ["previsionnel"]).notNull().default("previsionnel"),
|
||||
salaireAnnuelBrutHorsPrimesCents: int("salaireAnnuelBrutHorsPrimesCents").notNull(),
|
||||
salaireAnnuelBrutAvecPrimesCents: int("salaireAnnuelBrutAvecPrimesCents").notNull(),
|
||||
salaireMensuelBrutHorsPrimesCents: int("salaireMensuelBrutHorsPrimesCents").notNull(),
|
||||
tauxChargeBps: int("tauxChargeBps").notNull(),
|
||||
tauxChargeBps: int("tauxChargeBps"),
|
||||
/** Seules ces deux composantes de prévision sont éditables pour l'exercice courant ou futur. */
|
||||
primeAstreinteCents: int("primeAstreinteCents").notNull().default(0),
|
||||
primeCents: int("primeCents").notNull().default(0),
|
||||
evolutionCents: int("evolutionCents").notNull().default(0),
|
||||
/** Valeurs à l'import, utilisées pour recalculer le prévisionnel par différence lors d'une édition. */
|
||||
primeReferenceCents: int("primeReferenceCents").notNull().default(0),
|
||||
evolutionReferenceCents: int("evolutionReferenceCents").notNull().default(0),
|
||||
/** Mois de paie ayant servi de référence, ex. 2025-12 ou 2026-08. */
|
||||
periodeReference: varchar("periodeReference", { length: 7 }),
|
||||
/** `annuel` pour un exercice clos ; `cumul_provisoire` pour une année en cours. */
|
||||
@@ -242,8 +251,8 @@ export const masseSalarialeRemunerations = mysqlTable("masse_salariale_remunerat
|
||||
createdAt: timestamp("createdAt").defaultNow().notNull(),
|
||||
updatedAt: timestamp("updatedAt").defaultNow().onUpdateNow().notNull(),
|
||||
}, (t) => ({
|
||||
/** Rend les imports idempotents pour un salarié et un exercice. */
|
||||
uniqSalarieAnnee: uniqueIndex("masse_salariale_salarie_annee_idx").on(t.salarieId, t.annee),
|
||||
/** Rend les imports idempotents pour un salarié, exercice et nature de donnée. */
|
||||
uniqSalarieAnneeNature: uniqueIndex("masse_salariale_salarie_annee_nature_idx").on(t.salarieId, t.annee, t.nature),
|
||||
}));
|
||||
|
||||
export type MasseSalarialeRemuneration = typeof masseSalarialeRemunerations.$inferSelect;
|
||||
@@ -265,3 +274,64 @@ export const masseSalarialeEvolutions = mysqlTable("masse_salariale_evolutions",
|
||||
|
||||
export type MasseSalarialeEvolution = typeof masseSalarialeEvolutions.$inferSelect;
|
||||
export type InsertMasseSalarialeEvolution = typeof masseSalarialeEvolutions.$inferInsert;
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// SALAIRES MENSUELS — Liasses PDF chiffrées et données extraites
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Une liasse représente un dépôt mensuel. Le fichier est chiffré avant envoi
|
||||
* vers le stockage persistant ; la base ne conserve ni octet de PDF ni clé.
|
||||
*/
|
||||
export const salairesLiasses = mysqlTable("salaires_liasses", {
|
||||
id: int("id").autoincrement().primaryKey(),
|
||||
annee: int("annee").notNull(),
|
||||
mois: int("mois").notNull(),
|
||||
nomFichier: varchar("nomFichier", { length: 255 }).notNull(),
|
||||
stockageKey: varchar("stockageKey", { length: 512 }).notNull().unique(),
|
||||
empreinteSha256: varchar("empreinteSha256", { length: 64 }).notNull().unique(),
|
||||
tailleOctets: int("tailleOctets").notNull(),
|
||||
/** Paramètres AES-256-GCM nécessaires au déchiffrement côté serveur. */
|
||||
ivBase64: varchar("ivBase64", { length: 32 }).notNull(),
|
||||
authTagBase64: varchar("authTagBase64", { length: 32 }).notNull(),
|
||||
versionCle: int("versionCle").notNull().default(1),
|
||||
/** `ready`, `partiel` ou `a_controler` : une liasse est conservée même si l'extraction est incomplète. */
|
||||
statutExtraction: varchar("statutExtraction", { length: 32 }).notNull().default("ready"),
|
||||
erreurExtraction: text("erreurExtraction"),
|
||||
importePar: int("importePar").notNull(),
|
||||
importeLe: timestamp("importeLe").defaultNow().notNull(),
|
||||
}, (t) => ({
|
||||
/** Une liasse unique par mois : toutes les fiches mensuelles restent ainsi cohérentes. */
|
||||
uniqPeriode: uniqueIndex("salaires_liasses_periode_idx").on(t.annee, t.mois),
|
||||
/** Empêche de traiter deux fois le même fichier sous une période distincte. */
|
||||
uniqPeriodeEmpreinte: uniqueIndex("salaires_liasses_periode_empreinte_idx").on(t.annee, t.mois, t.empreinteSha256),
|
||||
}));
|
||||
|
||||
export type SalaireLiasse = typeof salairesLiasses.$inferSelect;
|
||||
export type InsertSalaireLiasse = typeof salairesLiasses.$inferInsert;
|
||||
|
||||
/**
|
||||
* Données utiles par bulletin. `explicationEcart` conserve uniquement les
|
||||
* rubriques de rémunération nécessaires à expliquer une variation mensuelle.
|
||||
*/
|
||||
export const salairesBulletins = mysqlTable("salaires_bulletins", {
|
||||
id: int("id").autoincrement().primaryKey(),
|
||||
liasseId: int("liasseId").notNull(),
|
||||
matricule: varchar("matricule", { length: 32 }).notNull(),
|
||||
nom: varchar("nom", { length: 150 }).notNull(),
|
||||
prenom: varchar("prenom", { length: 150 }).notNull(),
|
||||
poste: varchar("poste", { length: 255 }).notNull(),
|
||||
brutMensuelCents: int("brutMensuelCents").notNull(),
|
||||
brutAvecPrimesCents: int("brutAvecPrimesCents").notNull(),
|
||||
primeAstreinteCents: int("primeAstreinteCents").notNull().default(0),
|
||||
explicationEcart: text("explicationEcart"),
|
||||
numeroPage: int("numeroPage"),
|
||||
createdAt: timestamp("createdAt").defaultNow().notNull(),
|
||||
updatedAt: timestamp("updatedAt").defaultNow().onUpdateNow().notNull(),
|
||||
}, (t) => ({
|
||||
/** Un seul bulletin indexé par matricule dans une liasse donnée. */
|
||||
uniqLiasseMatricule: uniqueIndex("salaires_bulletins_liasse_matricule_idx").on(t.liasseId, t.matricule),
|
||||
}));
|
||||
|
||||
export type SalaireBulletin = typeof salairesBulletins.$inferSelect;
|
||||
export type InsertSalaireBulletin = typeof salairesBulletins.$inferInsert;
|
||||
|
||||
@@ -62,9 +62,11 @@
|
||||
"input-otp": "^1.4.2",
|
||||
"jose": "6.1.0",
|
||||
"lucide-react": "^0.453.0",
|
||||
"multer": "2.0.2",
|
||||
"mysql2": "^3.15.0",
|
||||
"nanoid": "^5.1.5",
|
||||
"next-themes": "^0.4.6",
|
||||
"pdf-parse": "1.1.1",
|
||||
"react": "^19.2.1",
|
||||
"react-day-picker": "^9.11.1",
|
||||
"react-dom": "^19.2.1",
|
||||
@@ -88,9 +90,11 @@
|
||||
"@types/bcryptjs": "^3.0.0",
|
||||
"@types/express": "4.17.21",
|
||||
"@types/google.maps": "^3.58.1",
|
||||
"@types/multer": "2.0.0",
|
||||
"@types/node": "^24.7.0",
|
||||
"@types/react": "^19.2.1",
|
||||
"@types/react-dom": "^19.2.1",
|
||||
"@types/supertest": "6.0.3",
|
||||
"@vitejs/plugin-react": "^5.0.4",
|
||||
"add": "^2.0.6",
|
||||
"autoprefixer": "^10.4.20",
|
||||
@@ -99,6 +103,7 @@
|
||||
"pnpm": "^10.15.1",
|
||||
"postcss": "^8.4.47",
|
||||
"prettier": "^3.6.2",
|
||||
"supertest": "7.1.4",
|
||||
"tailwindcss": "^4.1.14",
|
||||
"tsx": "^4.19.1",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
|
||||
312
pnpm-lock.yaml
generated
312
pnpm-lock.yaml
generated
@@ -160,6 +160,9 @@ importers:
|
||||
lucide-react:
|
||||
specifier: ^0.453.0
|
||||
version: 0.453.0(react@19.2.1)
|
||||
multer:
|
||||
specifier: 2.0.2
|
||||
version: 2.0.2
|
||||
mysql2:
|
||||
specifier: ^3.15.0
|
||||
version: 3.22.5(@types/node@24.7.0)
|
||||
@@ -169,6 +172,9 @@ importers:
|
||||
next-themes:
|
||||
specifier: ^0.4.6
|
||||
version: 0.4.6(react-dom@19.2.1(react@19.2.1))(react@19.2.1)
|
||||
pdf-parse:
|
||||
specifier: 1.1.1
|
||||
version: 1.1.1
|
||||
react:
|
||||
specifier: ^19.2.1
|
||||
version: 19.2.1
|
||||
@@ -233,6 +239,9 @@ importers:
|
||||
'@types/google.maps':
|
||||
specifier: ^3.58.1
|
||||
version: 3.58.1
|
||||
'@types/multer':
|
||||
specifier: 2.0.0
|
||||
version: 2.0.0
|
||||
'@types/node':
|
||||
specifier: ^24.7.0
|
||||
version: 24.7.0
|
||||
@@ -242,6 +251,9 @@ importers:
|
||||
'@types/react-dom':
|
||||
specifier: ^19.2.1
|
||||
version: 19.2.1(@types/react@19.2.1)
|
||||
'@types/supertest':
|
||||
specifier: 6.0.3
|
||||
version: 6.0.3
|
||||
'@vitejs/plugin-react':
|
||||
specifier: ^5.0.4
|
||||
version: 5.0.4(vite@7.1.9(@types/node@24.7.0)(jiti@2.6.1)(lightningcss@1.30.1)(tsx@4.20.6))
|
||||
@@ -266,6 +278,9 @@ importers:
|
||||
prettier:
|
||||
specifier: ^3.6.2
|
||||
version: 3.6.2
|
||||
supertest:
|
||||
specifier: 7.1.4
|
||||
version: 7.1.4
|
||||
tailwindcss:
|
||||
specifier: ^4.1.14
|
||||
version: 4.1.14
|
||||
@@ -1164,9 +1179,16 @@ packages:
|
||||
'@mermaid-js/parser@0.6.3':
|
||||
resolution: {integrity: sha512-lnjOhe7zyHjc+If7yT4zoedx2vo4sHaTmtkl1+or8BRTnCtDmcTpAjpzDSfCZrshM5bCoz0GyidzadJAH1xobA==}
|
||||
|
||||
'@noble/hashes@1.8.0':
|
||||
resolution: {integrity: sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==}
|
||||
engines: {node: ^14.21.3 || >=16}
|
||||
|
||||
'@nodable/entities@2.1.1':
|
||||
resolution: {integrity: sha512-Pig3HxDIoMgjdEH8OCf/dkcTmLFjJRjWuq8jSnklu284/TKOPibSRERmOykiwmyXTtv61mP+44f3GMx0tLAyjg==}
|
||||
|
||||
'@paralleldrive/cuid2@2.3.1':
|
||||
resolution: {integrity: sha512-XO7cAxhnTZl0Yggq6jOgjiOHhbgcO4NqFqwSmQpjK3b6TEE6Uj/jfSk6wzYyemh3+I0sHirKSetjQwn5cZktFw==}
|
||||
|
||||
'@radix-ui/number@1.1.1':
|
||||
resolution: {integrity: sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g==}
|
||||
|
||||
@@ -2099,6 +2121,9 @@ packages:
|
||||
'@types/connect@3.4.38':
|
||||
resolution: {integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==}
|
||||
|
||||
'@types/cookiejar@2.1.5':
|
||||
resolution: {integrity: sha512-he+DHOWReW0nghN24E1WUqM0efK4kI9oTqDm6XmK8ZPe2djZ90BSNdGnIyCLzCPw7/pogPlGbzI2wHGGmi4O/Q==}
|
||||
|
||||
'@types/d3-array@3.2.2':
|
||||
resolution: {integrity: sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==}
|
||||
|
||||
@@ -2225,12 +2250,18 @@ packages:
|
||||
'@types/mdast@4.0.4':
|
||||
resolution: {integrity: sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==}
|
||||
|
||||
'@types/methods@1.1.4':
|
||||
resolution: {integrity: sha512-ymXWVrDiCxTBE3+RIrrP533E70eA+9qu7zdWoHuOmGujkYtzf4HQF96b8nwHLqhuf4ykX61IGRIB38CC6/sImQ==}
|
||||
|
||||
'@types/mime@1.3.5':
|
||||
resolution: {integrity: sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==}
|
||||
|
||||
'@types/ms@2.1.0':
|
||||
resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==}
|
||||
|
||||
'@types/multer@2.0.0':
|
||||
resolution: {integrity: sha512-C3Z9v9Evij2yST3RSBktxP9STm6OdMc5uR1xF1SGr98uv8dUlAL2hqwrZ3GVB3uyMyiegnscEK6PGtYvNrjTjw==}
|
||||
|
||||
'@types/node@24.7.0':
|
||||
resolution: {integrity: sha512-IbKooQVqUBrlzWTi79E8Fw78l8k1RNtlDDNWsFZs7XonuQSJ8oNYfEeclhprUldXISRMLzBpILuKgPlIxm+/Yw==}
|
||||
|
||||
@@ -2257,6 +2288,12 @@ packages:
|
||||
'@types/serve-static@1.15.9':
|
||||
resolution: {integrity: sha512-dOTIuqpWLyl3BBXU3maNQsS4A3zuuoYRNIvYSxxhebPfXg2mzWQEPne/nlJ37yOse6uGgR386uTpdsx4D0QZWA==}
|
||||
|
||||
'@types/superagent@8.1.11':
|
||||
resolution: {integrity: sha512-KA7srSW/HENDtOw9DOqaFLgWuMqN9WgjEw62lh9dpvRaZDkhdOkazASd7X7i2eMUYLHa1U37ZttnePsH5zTDHw==}
|
||||
|
||||
'@types/supertest@6.0.3':
|
||||
resolution: {integrity: sha512-8WzXq62EXFhJ7QsH3Ocb/iKQ/Ty9ZVWnVzoTKc9tyyFRRF3a74Tk2+TLFgaFFw364Ere+npzHKEJ6ga2LzIL7w==}
|
||||
|
||||
'@types/trusted-types@2.0.7':
|
||||
resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==}
|
||||
|
||||
@@ -2323,6 +2360,9 @@ packages:
|
||||
anynum@1.0.0:
|
||||
resolution: {integrity: sha512-xjR9/zBVnUOP6ztMIIgShjsxui80nQUQH+5xJnvrYLs+90bF25/KJqaAi8mk+B4RDtX1Nspi6fmp4YTEts8SfA==}
|
||||
|
||||
append-field@1.0.0:
|
||||
resolution: {integrity: sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==}
|
||||
|
||||
aria-hidden@1.2.6:
|
||||
resolution: {integrity: sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==}
|
||||
engines: {node: '>=10'}
|
||||
@@ -2330,6 +2370,9 @@ packages:
|
||||
array-flatten@1.1.1:
|
||||
resolution: {integrity: sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==}
|
||||
|
||||
asap@2.0.6:
|
||||
resolution: {integrity: sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==}
|
||||
|
||||
assertion-error@2.0.1:
|
||||
resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==}
|
||||
engines: {node: '>=12'}
|
||||
@@ -2377,6 +2420,10 @@ packages:
|
||||
buffer-from@1.1.2:
|
||||
resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==}
|
||||
|
||||
busboy@1.6.0:
|
||||
resolution: {integrity: sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==}
|
||||
engines: {node: '>=10.16.0'}
|
||||
|
||||
bytes@3.1.2:
|
||||
resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==}
|
||||
engines: {node: '>= 0.8'}
|
||||
@@ -2467,6 +2514,13 @@ packages:
|
||||
resolution: {integrity: sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==}
|
||||
engines: {node: '>= 12'}
|
||||
|
||||
component-emitter@1.3.1:
|
||||
resolution: {integrity: sha512-T0+barUSQRTUQASh8bx02dl+DhF54GtIDY13Y3m9oWTklKbb3Wv974meRpeZ3lp1JpLVECWWNHC4vaG2XHXouQ==}
|
||||
|
||||
concat-stream@2.0.0:
|
||||
resolution: {integrity: sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==}
|
||||
engines: {'0': node >= 6.0}
|
||||
|
||||
confbox@0.1.8:
|
||||
resolution: {integrity: sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==}
|
||||
|
||||
@@ -2495,6 +2549,9 @@ packages:
|
||||
resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
cookiejar@2.1.4:
|
||||
resolution: {integrity: sha512-LDx6oHrK+PhzLKJU9j5S7/Y3jM/mUHvD/DeI1WQmJn652iPC5Y4TBzC9l+5OMOXlyTTA+SmVUPm0HQUwpD5Jqw==}
|
||||
|
||||
copy-anything@3.0.5:
|
||||
resolution: {integrity: sha512-yCEafptTtb4bk7GLEQoM8KVJpxAfdBJYaXyzQEgQQQgYrZiDp8SJmGKlYza6CYjEDNstAdNdKA3UuoULlEbS6w==}
|
||||
engines: {node: '>=12.13'}
|
||||
@@ -2691,6 +2748,14 @@ packages:
|
||||
supports-color:
|
||||
optional: true
|
||||
|
||||
debug@3.2.7:
|
||||
resolution: {integrity: sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==}
|
||||
peerDependencies:
|
||||
supports-color: '*'
|
||||
peerDependenciesMeta:
|
||||
supports-color:
|
||||
optional: true
|
||||
|
||||
debug@4.4.3:
|
||||
resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==}
|
||||
engines: {node: '>=6.0'}
|
||||
@@ -2743,6 +2808,9 @@ packages:
|
||||
devlop@1.1.0:
|
||||
resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==}
|
||||
|
||||
dezalgo@1.0.4:
|
||||
resolution: {integrity: sha512-rXSP0bf+5n0Qonsb+SVVfNfIsimO4HEtmnIpPHY8Q1UCzKlQrDMfdobr8nJOOsRgWCyMRqeSBQzmWUMq7zvVig==}
|
||||
|
||||
dom-helpers@5.2.1:
|
||||
resolution: {integrity: sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==}
|
||||
|
||||
@@ -2972,6 +3040,9 @@ packages:
|
||||
resolution: {integrity: sha512-6rxyATwPCkaFIL3JLqw8qXqMpIZ942pTX/tbQFkRsDGblS8tNGtlUauA/+mt6RUfqn/4MoEr+WDkYoIQbibWuQ==}
|
||||
engines: {node: '>=6.0.0'}
|
||||
|
||||
fast-safe-stringify@2.1.1:
|
||||
resolution: {integrity: sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==}
|
||||
|
||||
fast-xml-builder@1.2.0:
|
||||
resolution: {integrity: sha512-00aAWieqff+ZJhsXA4g1g7M8k+7AYoMUUHF+/zFb5U6Uv/P0Vl4QZo84/IcufzYalLuEj9928bXN9PbbFzMF0Q==}
|
||||
|
||||
@@ -3005,6 +3076,14 @@ packages:
|
||||
resolution: {integrity: sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==}
|
||||
engines: {node: '>= 6'}
|
||||
|
||||
form-data@4.0.6:
|
||||
resolution: {integrity: sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==}
|
||||
engines: {node: '>= 6'}
|
||||
|
||||
formidable@3.5.4:
|
||||
resolution: {integrity: sha512-YikH+7CUTOtP44ZTnUhR7Ic2UASBPOqmaRkRKxRbywPTe5VxF7RRCck4af9wutiZ/QKM5nME9Bie2fFaPz5Gug==}
|
||||
engines: {node: '>=14.0.0'}
|
||||
|
||||
forwarded@0.2.0:
|
||||
resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==}
|
||||
engines: {node: '>= 0.6'}
|
||||
@@ -3090,6 +3169,10 @@ packages:
|
||||
resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
hasown@2.0.4:
|
||||
resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
hast-util-from-dom@5.0.1:
|
||||
resolution: {integrity: sha512-N+LqofjR2zuzTjCPzyDUdSshy4Ma6li7p/c3pA78uTwzFgENbgbUrm2ugwsOdcjI1muO+o6Dgzp9p8WHtn/39Q==}
|
||||
|
||||
@@ -3518,6 +3601,14 @@ packages:
|
||||
engines: {node: '>=4'}
|
||||
hasBin: true
|
||||
|
||||
mime@2.6.0:
|
||||
resolution: {integrity: sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==}
|
||||
engines: {node: '>=4.0.0'}
|
||||
hasBin: true
|
||||
|
||||
minimist@1.2.8:
|
||||
resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==}
|
||||
|
||||
minipass@7.1.2:
|
||||
resolution: {integrity: sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==}
|
||||
engines: {node: '>=16 || 14 >=14.17'}
|
||||
@@ -3529,6 +3620,10 @@ packages:
|
||||
mitt@3.0.1:
|
||||
resolution: {integrity: sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==}
|
||||
|
||||
mkdirp@0.5.6:
|
||||
resolution: {integrity: sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==}
|
||||
hasBin: true
|
||||
|
||||
mlly@1.8.0:
|
||||
resolution: {integrity: sha512-l8D9ODSRWLe2KHJSifWGwBqpTZXIXTeo8mlKjY+E2HAakaTeNpqAyBZ8GSqLzHgw4XmHmC8whvpjJNMbFZN7/g==}
|
||||
|
||||
@@ -3547,6 +3642,10 @@ packages:
|
||||
ms@2.1.3:
|
||||
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
|
||||
|
||||
multer@2.0.2:
|
||||
resolution: {integrity: sha512-u7f2xaZ/UG8oLXHvtF/oWTRvT44p9ecwBBqTwgJVq0+4BW1g8OW01TyMEGWBHbyMOYVHXslaut7qEQ1meATXgw==}
|
||||
engines: {node: '>= 10.16.0'}
|
||||
|
||||
mysql2@3.22.5:
|
||||
resolution: {integrity: sha512-95uZ2TrPWAZdwpB3vvvDbmEMcNG8yIeNCyu6GUcr/QnWEE/wXm7+mhOCsdQfWQDTV7qYT/PDUZ4U4UPP4AsXqQ==}
|
||||
engines: {node: '>= 8.0'}
|
||||
@@ -3577,6 +3676,9 @@ packages:
|
||||
react: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc
|
||||
react-dom: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc
|
||||
|
||||
node-ensure@0.0.0:
|
||||
resolution: {integrity: sha512-DRI60hzo2oKN1ma0ckc6nQWlHU69RH6xN0sjQTjMpChPfTYvKZdcQFfdYK2RWbJcKyUizSIy/l8OTGxMAM1QDw==}
|
||||
|
||||
node-releases@2.0.23:
|
||||
resolution: {integrity: sha512-cCmFDMSm26S6tQSDpBCg/NR8NENrVPhAJSf+XbxBG4rPFaaonlEoE9wHQmun+cls499TQGSb7ZyPBRlzgKfpeg==}
|
||||
|
||||
@@ -3596,6 +3698,9 @@ packages:
|
||||
resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==}
|
||||
engines: {node: '>= 0.8'}
|
||||
|
||||
once@1.4.0:
|
||||
resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==}
|
||||
|
||||
oniguruma-parser@0.12.1:
|
||||
resolution: {integrity: sha512-8Unqkvk1RYc6yq2WBYRj4hdnsAxVze8i7iPfQr8e4uSP3tRv0rpZcbGUDvxfQQcdwHt/e9PrMvGCsa8OqG9X3w==}
|
||||
|
||||
@@ -3635,6 +3740,10 @@ packages:
|
||||
resolution: {integrity: sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==}
|
||||
engines: {node: '>= 14.16'}
|
||||
|
||||
pdf-parse@1.1.1:
|
||||
resolution: {integrity: sha512-v6ZJ/efsBpGrGGknjtq9J/oC8tZWq0KWL5vQrk2GlzLEQPUDB1ex+13Rmidl1neNN358Jn9EHZw5y07FFtaC7A==}
|
||||
engines: {node: '>=6.8.1'}
|
||||
|
||||
picocolors@1.1.1:
|
||||
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
|
||||
|
||||
@@ -3695,6 +3804,10 @@ packages:
|
||||
resolution: {integrity: sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg==}
|
||||
engines: {node: '>=0.6'}
|
||||
|
||||
qs@6.15.3:
|
||||
resolution: {integrity: sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==}
|
||||
engines: {node: '>=0.6'}
|
||||
|
||||
quansync@0.2.11:
|
||||
resolution: {integrity: sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA==}
|
||||
|
||||
@@ -3791,6 +3904,10 @@ packages:
|
||||
resolution: {integrity: sha512-DGrYcCWK7tvYMnWh79yrPHt+vdx9tY+1gPZa7nJQtO/p8bLTDaHp4dzwEhQB7pZ4Xe3ok4XKuEPrVuc+wlpkmw==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
readable-stream@3.6.2:
|
||||
resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==}
|
||||
engines: {node: '>= 6'}
|
||||
|
||||
recharts-scale@0.4.5:
|
||||
resolution: {integrity: sha512-kivNFO+0OcUNu7jQquLXAxz1FIwZj8nrj+YkOKc5694NbjCvcT6aSZiIzNzd2Kul4o4rTto8QVR9lMNtxD4G1w==}
|
||||
|
||||
@@ -3886,6 +4003,10 @@ packages:
|
||||
resolution: {integrity: sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
side-channel-list@1.0.1:
|
||||
resolution: {integrity: sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
side-channel-map@1.0.1:
|
||||
resolution: {integrity: sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==}
|
||||
engines: {node: '>= 0.4'}
|
||||
@@ -3898,6 +4019,10 @@ packages:
|
||||
resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
side-channel@1.1.1:
|
||||
resolution: {integrity: sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
siginfo@2.0.0:
|
||||
resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==}
|
||||
|
||||
@@ -3944,6 +4069,13 @@ packages:
|
||||
peerDependencies:
|
||||
react: ^18.0.0 || ^19.0.0
|
||||
|
||||
streamsearch@1.1.0:
|
||||
resolution: {integrity: sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==}
|
||||
engines: {node: '>=10.0.0'}
|
||||
|
||||
string_decoder@1.3.0:
|
||||
resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==}
|
||||
|
||||
stringify-entities@4.0.4:
|
||||
resolution: {integrity: sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==}
|
||||
|
||||
@@ -3959,10 +4091,18 @@ packages:
|
||||
stylis@4.3.6:
|
||||
resolution: {integrity: sha512-yQ3rwFWRfwNUY7H5vpU0wfdkNSnvnJinhF9830Swlaxl03zsOjCfmX0ugac+3LtK0lYSgwL/KXc8oYL3mG4YFQ==}
|
||||
|
||||
superagent@10.3.0:
|
||||
resolution: {integrity: sha512-B+4Ik7ROgVKrQsXTV0Jwp2u+PXYLSlqtDAhYnkkD+zn3yg8s/zjA2MeGayPoY/KICrbitwneDHrjSotxKL+0XQ==}
|
||||
engines: {node: '>=14.18.0'}
|
||||
|
||||
superjson@1.13.3:
|
||||
resolution: {integrity: sha512-mJiVjfd2vokfDxsQPOwJ/PtanO87LhpYY88ubI5dUB1Ab58Txbyje3+jpm+/83R/fevaq/107NNhtYBLuoTrFg==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
supertest@7.1.4:
|
||||
resolution: {integrity: sha512-tjLPs7dVyqgItVFirHYqe2T+MfWc2VOBQ8QFKKbWTA3PU7liZR8zoSpAi/C1k1ilm9RsXIKYf197oap9wXGVYg==}
|
||||
engines: {node: '>=14.18.0'}
|
||||
|
||||
tailwind-merge@3.3.1:
|
||||
resolution: {integrity: sha512-gBXpgUm/3rp1lMZZrM/w7D8GKqshif0zAymAhbCyIt8KMe+0v9DQ7cdYLR4FHH/cKpdTXb+A/tKKU3eolfsI+g==}
|
||||
|
||||
@@ -4044,6 +4184,9 @@ packages:
|
||||
resolution: {integrity: sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==}
|
||||
engines: {node: '>= 0.6'}
|
||||
|
||||
typedarray@0.0.6:
|
||||
resolution: {integrity: sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==}
|
||||
|
||||
typescript@5.9.3:
|
||||
resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==}
|
||||
engines: {node: '>=14.17'}
|
||||
@@ -4292,6 +4435,9 @@ packages:
|
||||
peerDependencies:
|
||||
react: '>=16.8.0'
|
||||
|
||||
wrappy@1.0.2:
|
||||
resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==}
|
||||
|
||||
xlsx@0.18.5:
|
||||
resolution: {integrity: sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==}
|
||||
engines: {node: '>=0.8'}
|
||||
@@ -4301,6 +4447,10 @@ packages:
|
||||
resolution: {integrity: sha512-k8KO9hrMyNk6tUWqUfkTEZbezRRpONVOzUTnc97VnCvyj6Tf9lyUR9EDAIeiVLv56jsMcoXEwjW8Kv5yPY52lw==}
|
||||
engines: {node: '>=16.0.0'}
|
||||
|
||||
xtend@4.0.2:
|
||||
resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==}
|
||||
engines: {node: '>=0.4'}
|
||||
|
||||
yallist@3.1.1:
|
||||
resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==}
|
||||
|
||||
@@ -5077,8 +5227,14 @@ snapshots:
|
||||
dependencies:
|
||||
langium: 3.3.1
|
||||
|
||||
'@noble/hashes@1.8.0': {}
|
||||
|
||||
'@nodable/entities@2.1.1': {}
|
||||
|
||||
'@paralleldrive/cuid2@2.3.1':
|
||||
dependencies:
|
||||
'@noble/hashes': 1.8.0
|
||||
|
||||
'@radix-ui/number@1.1.1': {}
|
||||
|
||||
'@radix-ui/primitive@1.1.3': {}
|
||||
@@ -6017,6 +6173,8 @@ snapshots:
|
||||
dependencies:
|
||||
'@types/node': 24.7.0
|
||||
|
||||
'@types/cookiejar@2.1.5': {}
|
||||
|
||||
'@types/d3-array@3.2.2': {}
|
||||
|
||||
'@types/d3-axis@3.0.6':
|
||||
@@ -6174,10 +6332,16 @@ snapshots:
|
||||
dependencies:
|
||||
'@types/unist': 3.0.3
|
||||
|
||||
'@types/methods@1.1.4': {}
|
||||
|
||||
'@types/mime@1.3.5': {}
|
||||
|
||||
'@types/ms@2.1.0': {}
|
||||
|
||||
'@types/multer@2.0.0':
|
||||
dependencies:
|
||||
'@types/express': 4.17.21
|
||||
|
||||
'@types/node@24.7.0':
|
||||
dependencies:
|
||||
undici-types: 7.14.0
|
||||
@@ -6209,6 +6373,18 @@ snapshots:
|
||||
'@types/node': 24.7.0
|
||||
'@types/send': 0.17.5
|
||||
|
||||
'@types/superagent@8.1.11':
|
||||
dependencies:
|
||||
'@types/cookiejar': 2.1.5
|
||||
'@types/methods': 1.1.4
|
||||
'@types/node': 24.7.0
|
||||
form-data: 4.0.4
|
||||
|
||||
'@types/supertest@6.0.3':
|
||||
dependencies:
|
||||
'@types/methods': 1.1.4
|
||||
'@types/superagent': 8.1.11
|
||||
|
||||
'@types/trusted-types@2.0.7':
|
||||
optional: true
|
||||
|
||||
@@ -6283,12 +6459,16 @@ snapshots:
|
||||
|
||||
anynum@1.0.0: {}
|
||||
|
||||
append-field@1.0.0: {}
|
||||
|
||||
aria-hidden@1.2.6:
|
||||
dependencies:
|
||||
tslib: 2.8.1
|
||||
|
||||
array-flatten@1.1.1: {}
|
||||
|
||||
asap@2.0.6: {}
|
||||
|
||||
assertion-error@2.0.1: {}
|
||||
|
||||
asynckit@0.4.0: {}
|
||||
@@ -6348,6 +6528,10 @@ snapshots:
|
||||
|
||||
buffer-from@1.1.2: {}
|
||||
|
||||
busboy@1.6.0:
|
||||
dependencies:
|
||||
streamsearch: 1.1.0
|
||||
|
||||
bytes@3.1.2: {}
|
||||
|
||||
cac@6.7.14: {}
|
||||
@@ -6435,6 +6619,15 @@ snapshots:
|
||||
|
||||
commander@8.3.0: {}
|
||||
|
||||
component-emitter@1.3.1: {}
|
||||
|
||||
concat-stream@2.0.0:
|
||||
dependencies:
|
||||
buffer-from: 1.1.2
|
||||
inherits: 2.0.4
|
||||
readable-stream: 3.6.2
|
||||
typedarray: 0.0.6
|
||||
|
||||
confbox@0.1.8: {}
|
||||
|
||||
confbox@0.2.2: {}
|
||||
@@ -6453,6 +6646,8 @@ snapshots:
|
||||
|
||||
cookie@1.1.1: {}
|
||||
|
||||
cookiejar@2.1.4: {}
|
||||
|
||||
copy-anything@3.0.5:
|
||||
dependencies:
|
||||
is-what: 4.1.16
|
||||
@@ -6665,6 +6860,10 @@ snapshots:
|
||||
dependencies:
|
||||
ms: 2.0.0
|
||||
|
||||
debug@3.2.7:
|
||||
dependencies:
|
||||
ms: 2.1.3
|
||||
|
||||
debug@4.4.3:
|
||||
dependencies:
|
||||
ms: 2.1.3
|
||||
@@ -6699,6 +6898,11 @@ snapshots:
|
||||
dependencies:
|
||||
dequal: 2.0.3
|
||||
|
||||
dezalgo@1.0.4:
|
||||
dependencies:
|
||||
asap: 2.0.6
|
||||
wrappy: 1.0.2
|
||||
|
||||
dom-helpers@5.2.1:
|
||||
dependencies:
|
||||
'@babel/runtime': 7.28.4
|
||||
@@ -6942,6 +7146,8 @@ snapshots:
|
||||
|
||||
fast-equals@5.3.2: {}
|
||||
|
||||
fast-safe-stringify@2.1.1: {}
|
||||
|
||||
fast-xml-builder@1.2.0:
|
||||
dependencies:
|
||||
path-expression-matcher: 1.5.0
|
||||
@@ -6980,6 +7186,20 @@ snapshots:
|
||||
hasown: 2.0.2
|
||||
mime-types: 2.1.35
|
||||
|
||||
form-data@4.0.6:
|
||||
dependencies:
|
||||
asynckit: 0.4.0
|
||||
combined-stream: 1.0.8
|
||||
es-set-tostringtag: 2.1.0
|
||||
hasown: 2.0.4
|
||||
mime-types: 2.1.35
|
||||
|
||||
formidable@3.5.4:
|
||||
dependencies:
|
||||
'@paralleldrive/cuid2': 2.3.1
|
||||
dezalgo: 1.0.4
|
||||
once: 1.4.0
|
||||
|
||||
forwarded@0.2.0: {}
|
||||
|
||||
frac@1.1.2: {}
|
||||
@@ -7050,6 +7270,10 @@ snapshots:
|
||||
dependencies:
|
||||
function-bind: 1.1.2
|
||||
|
||||
hasown@2.0.4:
|
||||
dependencies:
|
||||
function-bind: 1.1.2
|
||||
|
||||
hast-util-from-dom@5.0.1:
|
||||
dependencies:
|
||||
'@types/hast': 3.0.4
|
||||
@@ -7750,6 +7974,10 @@ snapshots:
|
||||
|
||||
mime@1.6.0: {}
|
||||
|
||||
mime@2.6.0: {}
|
||||
|
||||
minimist@1.2.8: {}
|
||||
|
||||
minipass@7.1.2: {}
|
||||
|
||||
minizlib@3.1.0:
|
||||
@@ -7758,6 +7986,10 @@ snapshots:
|
||||
|
||||
mitt@3.0.1: {}
|
||||
|
||||
mkdirp@0.5.6:
|
||||
dependencies:
|
||||
minimist: 1.2.8
|
||||
|
||||
mlly@1.8.0:
|
||||
dependencies:
|
||||
acorn: 8.15.0
|
||||
@@ -7777,6 +8009,16 @@ snapshots:
|
||||
|
||||
ms@2.1.3: {}
|
||||
|
||||
multer@2.0.2:
|
||||
dependencies:
|
||||
append-field: 1.0.0
|
||||
busboy: 1.6.0
|
||||
concat-stream: 2.0.0
|
||||
mkdirp: 0.5.6
|
||||
object-assign: 4.1.1
|
||||
type-is: 1.6.18
|
||||
xtend: 4.0.2
|
||||
|
||||
mysql2@3.22.5(@types/node@24.7.0):
|
||||
dependencies:
|
||||
'@types/node': 24.7.0
|
||||
@@ -7804,6 +8046,8 @@ snapshots:
|
||||
react: 19.2.1
|
||||
react-dom: 19.2.1(react@19.2.1)
|
||||
|
||||
node-ensure@0.0.0: {}
|
||||
|
||||
node-releases@2.0.23: {}
|
||||
|
||||
normalize-range@0.1.2: {}
|
||||
@@ -7816,6 +8060,10 @@ snapshots:
|
||||
dependencies:
|
||||
ee-first: 1.1.1
|
||||
|
||||
once@1.4.0:
|
||||
dependencies:
|
||||
wrappy: 1.0.2
|
||||
|
||||
oniguruma-parser@0.12.1: {}
|
||||
|
||||
oniguruma-to-es@4.3.3:
|
||||
@@ -7854,6 +8102,13 @@ snapshots:
|
||||
|
||||
pathval@2.0.1: {}
|
||||
|
||||
pdf-parse@1.1.1:
|
||||
dependencies:
|
||||
debug: 3.2.7
|
||||
node-ensure: 0.0.0
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
picocolors@1.1.1: {}
|
||||
|
||||
picomatch@4.0.3: {}
|
||||
@@ -7915,6 +8170,11 @@ snapshots:
|
||||
dependencies:
|
||||
side-channel: 1.1.0
|
||||
|
||||
qs@6.15.3:
|
||||
dependencies:
|
||||
es-define-property: 1.0.1
|
||||
side-channel: 1.1.1
|
||||
|
||||
quansync@0.2.11: {}
|
||||
|
||||
range-parser@1.2.1: {}
|
||||
@@ -8017,6 +8277,12 @@ snapshots:
|
||||
|
||||
react@19.2.1: {}
|
||||
|
||||
readable-stream@3.6.2:
|
||||
dependencies:
|
||||
inherits: 2.0.4
|
||||
string_decoder: 1.3.0
|
||||
util-deprecate: 1.0.2
|
||||
|
||||
recharts-scale@0.4.5:
|
||||
dependencies:
|
||||
decimal.js-light: 2.5.1
|
||||
@@ -8201,6 +8467,11 @@ snapshots:
|
||||
es-errors: 1.3.0
|
||||
object-inspect: 1.13.4
|
||||
|
||||
side-channel-list@1.0.1:
|
||||
dependencies:
|
||||
es-errors: 1.3.0
|
||||
object-inspect: 1.13.4
|
||||
|
||||
side-channel-map@1.0.1:
|
||||
dependencies:
|
||||
call-bound: 1.0.4
|
||||
@@ -8224,6 +8495,14 @@ snapshots:
|
||||
side-channel-map: 1.0.1
|
||||
side-channel-weakmap: 1.0.2
|
||||
|
||||
side-channel@1.1.1:
|
||||
dependencies:
|
||||
es-errors: 1.3.0
|
||||
object-inspect: 1.13.4
|
||||
side-channel-list: 1.0.1
|
||||
side-channel-map: 1.0.1
|
||||
side-channel-weakmap: 1.0.2
|
||||
|
||||
siginfo@2.0.0: {}
|
||||
|
||||
sonner@2.0.7(react-dom@19.2.1(react@19.2.1))(react@19.2.1):
|
||||
@@ -8274,6 +8553,12 @@ snapshots:
|
||||
- '@types/react'
|
||||
- supports-color
|
||||
|
||||
streamsearch@1.1.0: {}
|
||||
|
||||
string_decoder@1.3.0:
|
||||
dependencies:
|
||||
safe-buffer: 5.2.1
|
||||
|
||||
stringify-entities@4.0.4:
|
||||
dependencies:
|
||||
character-entities-html4: 2.1.0
|
||||
@@ -8293,10 +8578,31 @@ snapshots:
|
||||
|
||||
stylis@4.3.6: {}
|
||||
|
||||
superagent@10.3.0:
|
||||
dependencies:
|
||||
component-emitter: 1.3.1
|
||||
cookiejar: 2.1.4
|
||||
debug: 4.4.3
|
||||
fast-safe-stringify: 2.1.1
|
||||
form-data: 4.0.6
|
||||
formidable: 3.5.4
|
||||
methods: 1.1.2
|
||||
mime: 2.6.0
|
||||
qs: 6.15.3
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
superjson@1.13.3:
|
||||
dependencies:
|
||||
copy-anything: 3.0.5
|
||||
|
||||
supertest@7.1.4:
|
||||
dependencies:
|
||||
methods: 1.1.2
|
||||
superagent: 10.3.0
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
tailwind-merge@3.3.1: {}
|
||||
|
||||
tailwindcss-animate@1.0.7(tailwindcss@4.1.14):
|
||||
@@ -8364,6 +8670,8 @@ snapshots:
|
||||
media-typer: 0.3.0
|
||||
mime-types: 2.1.35
|
||||
|
||||
typedarray@0.0.6: {}
|
||||
|
||||
typescript@5.9.3: {}
|
||||
|
||||
ufo@1.6.1: {}
|
||||
@@ -8612,6 +8920,8 @@ snapshots:
|
||||
regexparam: 3.0.0
|
||||
use-sync-external-store: 1.6.0(react@19.2.1)
|
||||
|
||||
wrappy@1.0.2: {}
|
||||
|
||||
xlsx@0.18.5:
|
||||
dependencies:
|
||||
adler-32: 1.3.1
|
||||
@@ -8624,6 +8934,8 @@ snapshots:
|
||||
|
||||
xml-naming@0.1.0: {}
|
||||
|
||||
xtend@4.0.2: {}
|
||||
|
||||
yallist@3.1.1: {}
|
||||
|
||||
yallist@5.0.0: {}
|
||||
|
||||
68
scripts/analyzeMasseSalarialeWorkbook.py
Normal file
68
scripts/analyzeMasseSalarialeWorkbook.py
Normal file
@@ -0,0 +1,68 @@
|
||||
"""Analyse la structure du classeur de masse salariale sans afficher d'identité ni de montant."""
|
||||
from collections import Counter
|
||||
from pathlib import Path
|
||||
import re
|
||||
|
||||
from openpyxl import load_workbook
|
||||
|
||||
|
||||
workbook_path = Path("/home/ubuntu/upload/TableaurecapMassesalarialeSANTINOVA-28.08.26.xlsx")
|
||||
workbook = load_workbook(workbook_path, data_only=False)
|
||||
values_workbook = load_workbook(workbook_path, data_only=True)
|
||||
|
||||
for sheet in workbook.worksheets:
|
||||
print(f"sheet={sheet.title}|rows={sheet.max_row}|cols={sheet.max_column}")
|
||||
for row_index in range(1, min(sheet.max_row, 8) + 1):
|
||||
values = []
|
||||
for column_index in range(1, sheet.max_column + 1):
|
||||
value = sheet.cell(row_index, column_index).value
|
||||
if isinstance(value, str) and value.startswith("="):
|
||||
value = f"FORMULA:{value}"
|
||||
if value is not None:
|
||||
values.append(f"C{column_index}={value}")
|
||||
if values:
|
||||
print(f"row={row_index}|{' ; '.join(values)}")
|
||||
formula_count = sum(
|
||||
1
|
||||
for row in sheet.iter_rows()
|
||||
for cell in row
|
||||
if isinstance(cell.value, str) and cell.value.startswith("=")
|
||||
)
|
||||
print(f"formula_count={formula_count}")
|
||||
|
||||
salary_sheet = workbook["Masse salariale"]
|
||||
data_rows = [row for row in range(6, salary_sheet.max_row + 1) if salary_sheet.cell(row, 2).value]
|
||||
print(f"salaries_with_identifier={len(data_rows)}")
|
||||
for column, label in {
|
||||
8: "2025_brut_avec_primes",
|
||||
9: "2025_astreintes",
|
||||
11: "2025_taux_charges",
|
||||
12: "2025_evolution",
|
||||
15: "2026_astreintes",
|
||||
17: "2026_taux_charges",
|
||||
18: "2026_evolution",
|
||||
21: "2027_astreintes",
|
||||
23: "2027_evolution",
|
||||
24: "2027_prime",
|
||||
}.items():
|
||||
populated = sum(salary_sheet.cell(row, column).value is not None for row in data_rows)
|
||||
formulas = sum(
|
||||
isinstance(salary_sheet.cell(row, column).value, str)
|
||||
and salary_sheet.cell(row, column).value.startswith("=")
|
||||
for row in data_rows
|
||||
)
|
||||
print(f"field={label}|populated={populated}|formulas={formulas}")
|
||||
|
||||
for column, label in {7: "2025_hors_astreinte", 14: "2026_avec_primes", 20: "2027_avec_primes"}.items():
|
||||
formulas = Counter()
|
||||
for row in data_rows:
|
||||
value = salary_sheet.cell(row, column).value
|
||||
if isinstance(value, str) and value.startswith("="):
|
||||
formulas[re.sub(r"\d+", "#", value)] += 1
|
||||
for formula, count in formulas.items():
|
||||
print(f"formula_pattern={label}|count={count}|formula={formula}")
|
||||
|
||||
values_sheet = values_workbook["Masse salariale"]
|
||||
for column, label in {7: "2025_hors_astreinte", 10: "2025_mensuel", 13: "2026_hors_astreinte", 14: "2026_avec_primes", 16: "2026_mensuel", 19: "2027_hors_astreinte", 20: "2027_avec_primes", 22: "2027_mensuel"}.items():
|
||||
calculated = sum(values_sheet.cell(row, column).value is not None for row in data_rows)
|
||||
print(f"cached_value={label}|populated={calculated}")
|
||||
20
scripts/diagnoseMasseSalarialeWorkbook.ts
Normal file
20
scripts/diagnoseMasseSalarialeWorkbook.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import XLSX from "xlsx";
|
||||
|
||||
const workbook = XLSX.readFile("/home/ubuntu/upload/TableaurecapMassesalarialeSANTINOVA-28.08.26.xlsx", { cellDates: true });
|
||||
const sheet = workbook.Sheets["Masse salariale"];
|
||||
if (!sheet) throw new Error("Feuille Masse salariale introuvable");
|
||||
const rows = XLSX.utils.sheet_to_json<Array<unknown>>(sheet, { header: 1, raw: true, defval: null });
|
||||
const candidates = rows.slice(5).filter((row) => row[1] !== null && row[1] !== undefined && row[1] !== "");
|
||||
const typeCount = (index: number) => candidates.reduce<Record<string, number>>((counts, row) => {
|
||||
const kind = row[index] === null ? "null" : row[index] instanceof Date ? "date" : typeof row[index];
|
||||
counts[kind] = (counts[kind] ?? 0) + 1;
|
||||
return counts;
|
||||
}, {});
|
||||
console.log(JSON.stringify({
|
||||
candidates: candidates.length,
|
||||
matriculeTypes: typeCount(1),
|
||||
nomTypes: typeCount(2),
|
||||
prenomTypes: typeCount(3),
|
||||
posteTypes: typeCount(4),
|
||||
dateEmbaucheTypes: typeCount(5),
|
||||
}));
|
||||
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),
|
||||
}));
|
||||
24
scripts/importMasseSalarialeWorkbook.ts
Normal file
24
scripts/importMasseSalarialeWorkbook.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import XLSX from "xlsx";
|
||||
import { importMasseSalariale } from "../server/db";
|
||||
import { parseMasseSalarialeWorkbookRows } from "../server/masseSalarialeWorkbookImport";
|
||||
|
||||
const workbookPath = "/home/ubuntu/upload/TableaurecapMassesalarialeSANTINOVA-28.08.26.xlsx";
|
||||
const workbook = XLSX.readFile(workbookPath, { cellDates: true });
|
||||
const sheet = workbook.Sheets["Masse salariale"];
|
||||
if (!sheet) throw new Error("Feuille Masse salariale introuvable");
|
||||
|
||||
const rows = XLSX.utils.sheet_to_json<Array<string | number | Date | null>>(sheet, {
|
||||
header: 1,
|
||||
raw: true,
|
||||
defval: null,
|
||||
});
|
||||
const parsed = parseMasseSalarialeWorkbookRows(rows);
|
||||
const result = await importMasseSalariale(parsed.salaries, parsed.remunerations, {
|
||||
replaceForecastYears: [2025, 2026, 2027],
|
||||
});
|
||||
|
||||
const byYear = parsed.remunerations.reduce<Record<number, number>>((counts, row) => {
|
||||
counts[row.annee] = (counts[row.annee] ?? 0) + 1;
|
||||
return counts;
|
||||
}, {});
|
||||
console.log(JSON.stringify({ salaries: result.salaries, remunerationsByYear: byYear }));
|
||||
@@ -6,6 +6,9 @@ import { createExpressMiddleware } from "@trpc/server/adapters/express";
|
||||
import { registerOAuthRoutes } from "./oauth";
|
||||
import { registerStorageProxy } from "./storageProxy";
|
||||
import { appRouter } from "../routers";
|
||||
import { registerSalairesHttpRoutes } from "../salairesHttp";
|
||||
import { registerMasseSalarialeHttpRoutes } from "../masseSalarialeHttp";
|
||||
import { runDatabaseMigrations } from "../databaseMigrations";
|
||||
import { createContext } from "./context";
|
||||
import { serveStatic, setupVite } from "./vite";
|
||||
|
||||
@@ -29,6 +32,8 @@ async function findAvailablePort(startPort: number = 3000): Promise<number> {
|
||||
}
|
||||
|
||||
async function startServer() {
|
||||
// Une instance ne devient disponible qu'après la synchronisation du schéma.
|
||||
await runDatabaseMigrations();
|
||||
const app = express();
|
||||
const server = createServer(app);
|
||||
// Configure body parser with larger size limit for file uploads
|
||||
@@ -36,6 +41,8 @@ async function startServer() {
|
||||
app.use(express.urlencoded({ limit: "50mb", extended: true }));
|
||||
registerStorageProxy(app);
|
||||
registerOAuthRoutes(app);
|
||||
registerSalairesHttpRoutes(app);
|
||||
registerMasseSalarialeHttpRoutes(app);
|
||||
// tRPC API
|
||||
app.use(
|
||||
"/api/trpc",
|
||||
|
||||
22
server/appManifest.test.ts
Normal file
22
server/appManifest.test.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { resolve } from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
describe("manifeste applicatif Budget SI", () => {
|
||||
it("déclare les environnements, la catégorie et le dépôt Gitea attendus", async () => {
|
||||
const raw = await readFile(resolve(process.cwd(), "app.json"), "utf8");
|
||||
const manifest = JSON.parse(raw) as Record<string, unknown>;
|
||||
|
||||
expect(manifest).toMatchObject({
|
||||
id: "itinova-budget-si",
|
||||
name: "Gestion budget informatique",
|
||||
category: "SANTINOVA",
|
||||
giteaRepo: "manus-admin/itinova-budget-si",
|
||||
image: "images/budget-si.jpg",
|
||||
urls: {
|
||||
recette: "https://budget-si.recette.santinova-soft.org",
|
||||
prod: "https://budget-si.santinova-soft.org",
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -74,6 +74,8 @@ vi.mock("./db", async (importOriginal) => {
|
||||
getMasseSalariale: vi.fn(),
|
||||
importMasseSalariale: vi.fn(),
|
||||
setMasseSalarialeEvolution: vi.fn(),
|
||||
setMasseSalarialeForecastAdjustments: vi.fn(),
|
||||
getSalairesMensuels: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
@@ -355,11 +357,16 @@ describe("masseSalariale", () => {
|
||||
|
||||
it("retourne les données uniquement pour un administrateur", async () => {
|
||||
const { db } = await import("./db").then(m => ({ db: m }));
|
||||
const source = { salaries: [], remunerations: [], evolutions: [] };
|
||||
const source = { salaries: [], remunerations: [], evolutions: [], bulletinsMensuels: [] };
|
||||
(db.getMasseSalariale as ReturnType<typeof vi.fn>).mockResolvedValue(source);
|
||||
const { ctx } = createAuthCtx({ role: "admin" });
|
||||
|
||||
await expect(appRouter.createCaller(ctx).masseSalariale.get()).resolves.toEqual(source);
|
||||
await expect(appRouter.createCaller(ctx).masseSalariale.get()).resolves.toEqual({
|
||||
salaries: [],
|
||||
forecasts: [],
|
||||
actuals: [],
|
||||
currentYear: new Date().getFullYear(),
|
||||
});
|
||||
});
|
||||
|
||||
it("interdit la consultation à un profil standard", async () => {
|
||||
@@ -368,6 +375,43 @@ describe("masseSalariale", () => {
|
||||
await expect(appRouter.createCaller(ctx).masseSalariale.get()).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("construit le réel annuel uniquement depuis les bulletins mensuels archivés", async () => {
|
||||
const { db } = await import("./db").then(m => ({ db: m }));
|
||||
(db.getMasseSalariale as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
salaries: [{ id: 7, matricule: "000001", nom: "NOM", prenom: "PRENOM", poste: "Poste", dateEmbauche: "2024-01-01" }],
|
||||
remunerations: [{
|
||||
salarieId: 7,
|
||||
annee: 2026,
|
||||
salaireAnnuelBrutHorsPrimesCents: 1_000_000,
|
||||
salaireAnnuelBrutAvecPrimesCents: 1_200_000,
|
||||
salaireMensuelBrutHorsPrimesCents: 83_333,
|
||||
tauxChargeBps: 4_500,
|
||||
primeAstreinteCents: 200_000,
|
||||
primeCents: 0,
|
||||
primeReferenceCents: 0,
|
||||
evolutionCents: 0,
|
||||
evolutionReferenceCents: 0,
|
||||
}],
|
||||
evolutions: [],
|
||||
bulletinsMensuels: [
|
||||
{ annee: 2026, mois: 1, matricule: "000001", brutMensuelCents: 100_000, brutAvecPrimesCents: 120_000, primeAstreinteCents: 20_000 },
|
||||
{ annee: 2026, mois: 2, matricule: "000001", brutMensuelCents: 100_000, brutAvecPrimesCents: 110_000, primeAstreinteCents: 0 },
|
||||
],
|
||||
});
|
||||
const { ctx } = createAuthCtx({ role: "admin" });
|
||||
|
||||
const result = await appRouter.createCaller(ctx).masseSalariale.get();
|
||||
|
||||
expect(result.actuals).toEqual([expect.objectContaining({
|
||||
salarieId: 7,
|
||||
annee: 2026,
|
||||
brutAvecPrimesCents: 230_000,
|
||||
brutHorsAstreinteCents: 210_000,
|
||||
moisDisponibles: [1, 2],
|
||||
origine: "liasses",
|
||||
})]);
|
||||
});
|
||||
|
||||
it("importe les données validées uniquement pour un administrateur", async () => {
|
||||
const { db } = await import("./db").then(m => ({ db: m }));
|
||||
(db.importMasseSalariale as ReturnType<typeof vi.fn>).mockResolvedValue({ salaries: 1, remunerations: 1 });
|
||||
@@ -393,7 +437,15 @@ describe("masseSalariale", () => {
|
||||
|
||||
await expect(appRouter.createCaller(ctx).masseSalariale.import({ salaries, remunerations }))
|
||||
.resolves.toEqual({ salaries: 1, remunerations: 1 });
|
||||
expect(db.importMasseSalariale).toHaveBeenCalledWith(salaries, remunerations);
|
||||
expect(db.importMasseSalariale).toHaveBeenCalledWith(salaries, [{
|
||||
...remunerations[0],
|
||||
nature: "previsionnel",
|
||||
primeAstreinteCents: 0,
|
||||
primeCents: 0,
|
||||
evolutionCents: 0,
|
||||
primeReferenceCents: 0,
|
||||
evolutionReferenceCents: 0,
|
||||
}]);
|
||||
});
|
||||
|
||||
it("interdit l'import de rémunérations à un profil standard", async () => {
|
||||
@@ -433,4 +485,125 @@ describe("masseSalariale", () => {
|
||||
})).rejects.toThrow();
|
||||
expect(db.setMasseSalarialeEvolution).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("autorise un administrateur à modifier uniquement les primes et évolutions de l'exercice courant", async () => {
|
||||
const { db } = await import("./db").then(m => ({ db: m }));
|
||||
const { ctx } = createAuthCtx({ role: "admin" });
|
||||
const anneeCourante = new Date().getFullYear();
|
||||
|
||||
await expect(appRouter.createCaller(ctx).masseSalariale.setForecastAdjustments({
|
||||
salarieId: 7,
|
||||
annee: anneeCourante,
|
||||
primeCents: 12_500,
|
||||
evolutionCents: 20_000,
|
||||
})).resolves.toEqual({ success: true });
|
||||
|
||||
expect(db.setMasseSalarialeForecastAdjustments).toHaveBeenCalledWith({
|
||||
salarieId: 7,
|
||||
annee: anneeCourante,
|
||||
primeCents: 12_500,
|
||||
evolutionCents: 20_000,
|
||||
});
|
||||
});
|
||||
|
||||
it("interdit l'édition prévisionnelle d'un exercice antérieur", async () => {
|
||||
const { db } = await import("./db").then(m => ({ db: m }));
|
||||
const { ctx } = createAuthCtx({ role: "admin" });
|
||||
|
||||
await expect(appRouter.createCaller(ctx).masseSalariale.setForecastAdjustments({
|
||||
salarieId: 7,
|
||||
annee: new Date().getFullYear() - 1,
|
||||
primeCents: 12_500,
|
||||
evolutionCents: 20_000,
|
||||
})).rejects.toThrow();
|
||||
expect(db.setMasseSalarialeForecastAdjustments).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("interdit l'édition prévisionnelle à un profil standard", async () => {
|
||||
const { db } = await import("./db").then(m => ({ db: m }));
|
||||
const { ctx } = createAuthCtx({ role: "standard" });
|
||||
|
||||
await expect(appRouter.createCaller(ctx).masseSalariale.setForecastAdjustments({
|
||||
salarieId: 7,
|
||||
annee: new Date().getFullYear(),
|
||||
primeCents: 0,
|
||||
evolutionCents: 0,
|
||||
})).rejects.toThrow();
|
||||
expect(db.setMasseSalarialeForecastAdjustments).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" });
|
||||
const previousKey = process.env.PAYROLL_ENCRYPTION_KEY;
|
||||
process.env.PAYROLL_ENCRYPTION_KEY = Buffer.alloc(32, 9).toString("base64");
|
||||
try {
|
||||
await expect(appRouter.createCaller(ctx).salaires.getSecurityStatus())
|
||||
.resolves.toEqual({ ready: true, algorithm: "AES-256-GCM" });
|
||||
} finally {
|
||||
if (previousKey === undefined) delete process.env.PAYROLL_ENCRYPTION_KEY;
|
||||
else process.env.PAYROLL_ENCRYPTION_KEY = previousKey;
|
||||
}
|
||||
});
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
55
server/databaseMigrations.ts
Normal file
55
server/databaseMigrations.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import { migrate } from "drizzle-orm/mysql2/migrator";
|
||||
import { drizzle } from "drizzle-orm/mysql2";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { createPool } from "mysql2/promise";
|
||||
import bcrypt from "bcryptjs";
|
||||
import { users } from "../drizzle/schema";
|
||||
|
||||
const INITIAL_ADMIN_LOGIN = "adminItinova";
|
||||
const INITIAL_ADMIN_PASSWORD = process.env.INITIAL_ADMIN_PASSWORD ?? "Itinova69!";
|
||||
|
||||
async function ensureInitialAdmin(pool: ReturnType<typeof createPool>) {
|
||||
const database = drizzle(pool);
|
||||
const existingAdmin = await database
|
||||
.select({ id: users.id })
|
||||
.from(users)
|
||||
.where(eq(users.login, INITIAL_ADMIN_LOGIN))
|
||||
.limit(1);
|
||||
|
||||
if (existingAdmin.length > 0) return;
|
||||
|
||||
const passwordHash = await bcrypt.hash(INITIAL_ADMIN_PASSWORD, 12);
|
||||
await database.insert(users).values({
|
||||
login: INITIAL_ADMIN_LOGIN,
|
||||
email: "adminItinova@santinova-soft.org",
|
||||
firstName: "Admin",
|
||||
lastName: "Itinova",
|
||||
passwordHash,
|
||||
role: "admin",
|
||||
isActive: true,
|
||||
});
|
||||
console.log("[Database] Compte administrateur initial créé.");
|
||||
}
|
||||
|
||||
/**
|
||||
* Synchronise la base avec les migrations versionnées avant d'exposer l'API.
|
||||
* Une base sans DATABASE_URL reste utilisable en développement statique, mais
|
||||
* un déploiement connecté échoue explicitement si la migration ne peut aboutir.
|
||||
*/
|
||||
export async function runDatabaseMigrations() {
|
||||
const databaseUrl = process.env.DATABASE_URL;
|
||||
if (!databaseUrl) {
|
||||
console.warn("[Database] DATABASE_URL absent : migrations ignorées.");
|
||||
return;
|
||||
}
|
||||
|
||||
const pool = createPool(databaseUrl);
|
||||
try {
|
||||
const database = drizzle(pool);
|
||||
await migrate(database, { migrationsFolder: "./drizzle" });
|
||||
await ensureInitialAdmin(pool);
|
||||
console.log("[Database] Migrations Drizzle synchronisées.");
|
||||
} finally {
|
||||
await pool.end();
|
||||
}
|
||||
}
|
||||
197
server/db.ts
197
server/db.ts
@@ -1,4 +1,4 @@
|
||||
import { and, asc, eq } from "drizzle-orm";
|
||||
import { and, asc, desc, eq, inArray } from "drizzle-orm";
|
||||
import { drizzle } from "drizzle-orm/mysql2";
|
||||
import {
|
||||
capexLignes,
|
||||
@@ -21,6 +21,8 @@ import {
|
||||
opexPostes,
|
||||
opexValidated,
|
||||
parametresApp,
|
||||
salairesBulletins,
|
||||
salairesLiasses,
|
||||
users,
|
||||
} from "../drizzle/schema";
|
||||
import { isOpexEtablissementCode } from "../shared/opexValidation";
|
||||
@@ -476,12 +478,20 @@ export type MasseSalarialeImportRemuneration = Omit<
|
||||
export async function getMasseSalariale() {
|
||||
const db = await getDb();
|
||||
if (!db) throw new Error("Database not available");
|
||||
const [salaries, remunerations, evolutions] = await Promise.all([
|
||||
const [salaries, remunerations, evolutions, bulletinsMensuels] = await Promise.all([
|
||||
db.select().from(masseSalarialeSalaries).orderBy(asc(masseSalarialeSalaries.nom), asc(masseSalarialeSalaries.prenom)),
|
||||
db.select().from(masseSalarialeRemunerations),
|
||||
db.select().from(masseSalarialeEvolutions),
|
||||
db.select({
|
||||
annee: salairesLiasses.annee,
|
||||
mois: salairesLiasses.mois,
|
||||
matricule: salairesBulletins.matricule,
|
||||
brutMensuelCents: salairesBulletins.brutMensuelCents,
|
||||
brutAvecPrimesCents: salairesBulletins.brutAvecPrimesCents,
|
||||
primeAstreinteCents: salairesBulletins.primeAstreinteCents,
|
||||
}).from(salairesBulletins).innerJoin(salairesLiasses, eq(salairesBulletins.liasseId, salairesLiasses.id)),
|
||||
]);
|
||||
return { salaries, remunerations, evolutions };
|
||||
return { salaries, remunerations, evolutions, bulletinsMensuels };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -492,6 +502,7 @@ export async function getMasseSalariale() {
|
||||
export async function importMasseSalariale(
|
||||
salaries: MasseSalarialeImportSalarie[],
|
||||
remunerations: MasseSalarialeImportRemuneration[],
|
||||
options: { replaceForecastYears?: number[] } = {},
|
||||
) {
|
||||
const db = await getDb();
|
||||
if (!db) throw new Error("Database not available");
|
||||
@@ -518,6 +529,13 @@ export async function importMasseSalariale(
|
||||
.from(masseSalarialeSalaries);
|
||||
const idsByMatricule = new Map(persistedSalaries.map((row) => [row.matricule, row.id]));
|
||||
|
||||
if (options.replaceForecastYears?.length) {
|
||||
await tx.delete(masseSalarialeRemunerations).where(and(
|
||||
inArray(masseSalarialeRemunerations.annee, options.replaceForecastYears),
|
||||
eq(masseSalarialeRemunerations.nature, "previsionnel"),
|
||||
));
|
||||
}
|
||||
|
||||
for (const remuneration of remunerations) {
|
||||
const salarieId = idsByMatricule.get(remuneration.matricule);
|
||||
if (!salarieId) {
|
||||
@@ -533,6 +551,12 @@ export async function importMasseSalariale(
|
||||
salaireAnnuelBrutAvecPrimesCents: values.salaireAnnuelBrutAvecPrimesCents,
|
||||
salaireMensuelBrutHorsPrimesCents: values.salaireMensuelBrutHorsPrimesCents,
|
||||
tauxChargeBps: values.tauxChargeBps,
|
||||
nature: values.nature,
|
||||
primeAstreinteCents: values.primeAstreinteCents,
|
||||
primeCents: values.primeCents,
|
||||
evolutionCents: values.evolutionCents,
|
||||
primeReferenceCents: values.primeReferenceCents,
|
||||
evolutionReferenceCents: values.evolutionReferenceCents,
|
||||
periodeReference: values.periodeReference,
|
||||
statut: values.statut,
|
||||
source: values.source,
|
||||
@@ -543,6 +567,25 @@ export async function importMasseSalariale(
|
||||
});
|
||||
}
|
||||
|
||||
/** Met à jour uniquement les deux hypothèses qui peuvent être modifiées. */
|
||||
export async function setMasseSalarialeForecastAdjustments(input: {
|
||||
salarieId: number;
|
||||
annee: number;
|
||||
primeCents: number;
|
||||
evolutionCents: number;
|
||||
}) {
|
||||
const db = await getDb();
|
||||
if (!db) throw new Error("Database not available");
|
||||
await db.update(masseSalarialeRemunerations).set({
|
||||
primeCents: input.primeCents,
|
||||
evolutionCents: input.evolutionCents,
|
||||
}).where(and(
|
||||
eq(masseSalarialeRemunerations.salarieId, input.salarieId),
|
||||
eq(masseSalarialeRemunerations.annee, input.annee),
|
||||
eq(masseSalarialeRemunerations.nature, "previsionnel"),
|
||||
));
|
||||
}
|
||||
|
||||
/** Sauvegarde l'évolution manuelle sans dépendre de l'existence d'un bulletin. */
|
||||
export async function setMasseSalarialeEvolution(
|
||||
input: Pick<InsertMasseSalarialeEvolution, "salarieId" | "annee" | "evolutionSalarialeBps" | "createdBy">,
|
||||
@@ -559,3 +602,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 };
|
||||
}
|
||||
|
||||
13
server/dockerCompose.test.ts
Normal file
13
server/dockerCompose.test.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { resolve } from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
describe("docker-compose de recette", () => {
|
||||
it("utilise le nom unique du conteneur MySQL et évite l'alias générique partagé", async () => {
|
||||
const compose = await readFile(resolve(process.cwd(), "docker-compose.yml"), "utf8");
|
||||
|
||||
expect(compose).toContain("DATABASE_URL=mysql://budget_user:BudgetSI2027!@itinova-budget-si-db:3306/budget_si");
|
||||
expect(compose).not.toContain("DATABASE_URL=mysql://budget_user:BudgetSI2027!@db:3306/budget_si");
|
||||
expect(compose).toContain("BUDGET_SI_DOMAIN:-budget-si.recette.santinova-soft.org");
|
||||
});
|
||||
});
|
||||
14
server/dockerMigrationBootstrap.test.ts
Normal file
14
server/dockerMigrationBootstrap.test.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { resolve } from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
describe("bootstrap de migrations Docker", () => {
|
||||
it("exécute les migrations Drizzle avant de lancer le serveur", async () => {
|
||||
const server = await readFile(resolve(process.cwd(), "server/_core/index.ts"), "utf8");
|
||||
const migrationBootstrap = await readFile(resolve(process.cwd(), "server/databaseMigrations.ts"), "utf8");
|
||||
|
||||
expect(server).toContain("await runDatabaseMigrations()");
|
||||
expect(migrationBootstrap).toContain("migrate(");
|
||||
expect(migrationBootstrap).toContain("migrationsFolder: \"./drizzle\"");
|
||||
});
|
||||
});
|
||||
14
server/initialAdminBootstrap.test.ts
Normal file
14
server/initialAdminBootstrap.test.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { resolve } from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
describe("bootstrap du compte administrateur", () => {
|
||||
it("crée le compte administrateur requis après migration sans stocker de mot de passe en clair", async () => {
|
||||
const bootstrap = await readFile(resolve(process.cwd(), "server/databaseMigrations.ts"), "utf8");
|
||||
|
||||
expect(bootstrap).toContain('const INITIAL_ADMIN_LOGIN = "adminItinova"');
|
||||
expect(bootstrap).toContain("await ensureInitialAdmin(pool)");
|
||||
expect(bootstrap).toContain("await bcrypt.hash(INITIAL_ADMIN_PASSWORD");
|
||||
expect(bootstrap).not.toContain("passwordHash: INITIAL_ADMIN_PASSWORD,");
|
||||
});
|
||||
});
|
||||
58
server/masseSalarialeHttp.test.ts
Normal file
58
server/masseSalarialeHttp.test.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import express from "express";
|
||||
import request from "supertest";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("./_core/sdk", () => ({ sdk: { authenticateRequest: vi.fn() } }));
|
||||
vi.mock("./db", () => ({ importMasseSalariale: vi.fn() }));
|
||||
vi.mock("./masseSalarialeWorkbookImport", () => ({ parseMasseSalarialeWorkbookBuffer: vi.fn() }));
|
||||
|
||||
import { sdk } from "./_core/sdk";
|
||||
import { importMasseSalariale } from "./db";
|
||||
import { registerMasseSalarialeHttpRoutes } from "./masseSalarialeHttp";
|
||||
import { parseMasseSalarialeWorkbookBuffer } from "./masseSalarialeWorkbookImport";
|
||||
|
||||
function makeApp() {
|
||||
const app = express();
|
||||
registerMasseSalarialeHttpRoutes(app);
|
||||
return app;
|
||||
}
|
||||
|
||||
describe("route HTTP d'import Masse salariale", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("interdit l'import Excel à un profil non administrateur", async () => {
|
||||
(sdk.authenticateRequest as ReturnType<typeof vi.fn>).mockResolvedValue({ id: 2, role: "standard" });
|
||||
|
||||
await request(makeApp())
|
||||
.post("/api/masse-salariale/previsionnels")
|
||||
.attach("file", Buffer.from("PK\x03\x04 fichier Excel"), { filename: "masse.xlsx", contentType: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" })
|
||||
.expect(403);
|
||||
|
||||
expect(importMasseSalariale).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("importe un fichier Excel administrateur sans transmettre son contenu au client", async () => {
|
||||
(sdk.authenticateRequest as ReturnType<typeof vi.fn>).mockResolvedValue({ id: 1, role: "admin" });
|
||||
(parseMasseSalarialeWorkbookBuffer as ReturnType<typeof vi.fn>).mockReturnValue({
|
||||
salaries: [{ matricule: "000001", nom: "NOM", prenom: "PRENOM", poste: "Poste", dateEmbauche: "2024-01-01" }],
|
||||
remunerations: [{
|
||||
matricule: "000001", annee: 2026, nature: "previsionnel", salaireAnnuelBrutHorsPrimesCents: 100_000,
|
||||
salaireAnnuelBrutAvecPrimesCents: 110_000, salaireMensuelBrutHorsPrimesCents: 8_333,
|
||||
tauxChargeBps: 4_500, primeAstreinteCents: 10_000, primeCents: 0, primeReferenceCents: 0,
|
||||
evolutionCents: 0, evolutionReferenceCents: 0, periodeReference: "2026-08", statut: "previsionnel", source: "fichier_modele_2026-08-28",
|
||||
}],
|
||||
});
|
||||
(importMasseSalariale as ReturnType<typeof vi.fn>).mockResolvedValue({ salaries: 1, remunerations: 1 });
|
||||
|
||||
const response = await request(makeApp())
|
||||
.post("/api/masse-salariale/previsionnels")
|
||||
.attach("file", Buffer.from("PK\x03\x04 fichier Excel"), { filename: "masse.xlsx", contentType: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" })
|
||||
.expect(200);
|
||||
|
||||
expect(importMasseSalariale).toHaveBeenCalledWith(expect.any(Array), expect.any(Array), { replaceForecastYears: [2025, 2026, 2027] });
|
||||
expect(response.body).toEqual({ salaries: 1, remunerations: 1, years: [2025, 2026, 2027] });
|
||||
expect(JSON.stringify(response.body)).not.toContain("000001");
|
||||
});
|
||||
});
|
||||
52
server/masseSalarialeHttp.ts
Normal file
52
server/masseSalarialeHttp.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import type { Express, Request, Response } from "express";
|
||||
import multer from "multer";
|
||||
import { sdk } from "./_core/sdk";
|
||||
import { importMasseSalariale } from "./db";
|
||||
import { parseMasseSalarialeWorkbookBuffer } from "./masseSalarialeWorkbookImport";
|
||||
|
||||
const MAX_WORKBOOK_BYTES = 5 * 1024 * 1024;
|
||||
const upload = multer({ storage: multer.memoryStorage(), limits: { fileSize: MAX_WORKBOOK_BYTES, files: 1 } });
|
||||
|
||||
async function getAdminUser(req: Request, res: Response) {
|
||||
try {
|
||||
const user = await sdk.authenticateRequest(req);
|
||||
if (user.role === "admin") return user;
|
||||
res.status(403).json({ error: "Accès réservé aux administrateurs." });
|
||||
} catch {
|
||||
res.status(401).json({ error: "Authentification requise." });
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function isXlsx(file: Express.Multer.File | undefined): file is Express.Multer.File {
|
||||
if (!file) return false;
|
||||
return file.originalname.toLowerCase().endsWith(".xlsx")
|
||||
&& file.buffer.subarray(0, 2).toString("ascii") === "PK";
|
||||
}
|
||||
|
||||
/**
|
||||
* Cette route est volontairement limitée au transfert binaire du classeur.
|
||||
* Elle ne renvoie jamais les lignes salariales : la page relit ensuite son
|
||||
* agrégat tRPC administrateur.
|
||||
*/
|
||||
export function registerMasseSalarialeHttpRoutes(app: Express) {
|
||||
app.post("/api/masse-salariale/previsionnels", upload.single("file"), async (req, res) => {
|
||||
const user = await getAdminUser(req, res);
|
||||
if (!user) return;
|
||||
if (!isXlsx(req.file)) {
|
||||
res.status(400).json({ error: "Transmettez un fichier .xlsx conforme (5 Mo maximum)." });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = parseMasseSalarialeWorkbookBuffer(req.file.buffer);
|
||||
const result = await importMasseSalariale(parsed.salaries, parsed.remunerations, {
|
||||
replaceForecastYears: [2025, 2026, 2027],
|
||||
});
|
||||
res.json({ salaries: result.salaries, remunerations: result.remunerations, years: [2025, 2026, 2027] });
|
||||
} catch (error) {
|
||||
console.error("[Masse salariale] Échec import prévisionnel", error instanceof Error ? error.message : "erreur inconnue");
|
||||
res.status(422).json({ error: "Le fichier n'a pas pu être importé. Les prévisions existantes sont conservées." });
|
||||
}
|
||||
});
|
||||
}
|
||||
75
server/masseSalarialeProjection.test.ts
Normal file
75
server/masseSalarialeProjection.test.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
buildAnnualForecasts,
|
||||
buildAnnualPayrollActuals,
|
||||
isForecastAdjustmentEditable,
|
||||
} from "./masseSalarialeProjection";
|
||||
|
||||
describe("projection prévisionnelle et réelle de masse salariale", () => {
|
||||
it("duplique les données closes 2025 entre prévisionnel et réel", () => {
|
||||
const forecasts = buildAnnualForecasts([
|
||||
{
|
||||
annee: 2025,
|
||||
brutHorsAstreinteReferenceCents: 108_000,
|
||||
brutAvecPrimesReferenceCents: 120_000,
|
||||
primeAstreinteCents: 12_000,
|
||||
primeCents: 0,
|
||||
primeReferenceCents: 0,
|
||||
evolutionCents: 0,
|
||||
evolutionReferenceCents: 0,
|
||||
tauxChargeBps: 4_500,
|
||||
},
|
||||
]);
|
||||
|
||||
expect(forecasts.get(2025)).toMatchObject({
|
||||
brutHorsAstreinteCents: 108_000,
|
||||
brutAvecPrimesCents: 120_000,
|
||||
brutMensuelHorsAstreinteCents: 9_000,
|
||||
tauxChargeBps: 4_500,
|
||||
});
|
||||
});
|
||||
|
||||
it("recalcule le prévisionnel futur à partir de la référence importée et des seules modifications manuelles", () => {
|
||||
const forecasts = buildAnnualForecasts([
|
||||
{
|
||||
annee: 2026,
|
||||
brutHorsAstreinteReferenceCents: 114_000,
|
||||
brutAvecPrimesReferenceCents: 119_000,
|
||||
primeAstreinteCents: 6_000,
|
||||
primeCents: 1_500,
|
||||
primeReferenceCents: 1_000,
|
||||
evolutionCents: 6_000,
|
||||
evolutionReferenceCents: 5_000,
|
||||
tauxChargeBps: 4_600,
|
||||
},
|
||||
]);
|
||||
|
||||
expect(forecasts.get(2026)).toMatchObject({
|
||||
brutHorsAstreinteCents: 115_500,
|
||||
brutAvecPrimesCents: 120_500,
|
||||
brutMensuelHorsAstreinteCents: 9_625,
|
||||
});
|
||||
});
|
||||
|
||||
it("calcule le réel 2026 à partir des seuls bulletins mensuels archivés, sans extrapolation", () => {
|
||||
const actuals = buildAnnualPayrollActuals([
|
||||
{ annee: 2026, mois: 5, matricule: "test-1", brutMensuelCents: 10_000, brutAvecPrimesCents: 12_000, primeAstreinteCents: 1_000 },
|
||||
{ annee: 2026, mois: 6, matricule: "test-1", brutMensuelCents: 10_000, brutAvecPrimesCents: 11_000, primeAstreinteCents: 0 },
|
||||
]);
|
||||
|
||||
expect(actuals.get("test-1")?.get(2026)).toEqual({
|
||||
brutHorsAstreinteCents: 22_000,
|
||||
brutAvecPrimesCents: 23_000,
|
||||
brutMensuelHorsAstreinteCents: 11_000,
|
||||
primeAstreinteCents: 1_000,
|
||||
primeComplementaireCents: 2_000,
|
||||
moisDisponibles: [5, 6],
|
||||
});
|
||||
});
|
||||
|
||||
it("n'autorise la modification des primes et évolutions qu'à partir de l'année courante", () => {
|
||||
expect(isForecastAdjustmentEditable(2025, 2026)).toBe(false);
|
||||
expect(isForecastAdjustmentEditable(2026, 2026)).toBe(true);
|
||||
expect(isForecastAdjustmentEditable(2027, 2026)).toBe(true);
|
||||
});
|
||||
});
|
||||
112
server/masseSalarialeProjection.ts
Normal file
112
server/masseSalarialeProjection.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
/**
|
||||
* Calculs purs de la fenêtre Masse salariale. Les montants sont conservés en
|
||||
* centimes afin que les projections et cumuls ne subissent aucun arrondi
|
||||
* intermédiaire. Les réels ne sont jamais extrapolés : ils cumulent seulement
|
||||
* les liasses mensuelles effectivement archivées.
|
||||
*/
|
||||
|
||||
export type AnnualForecastInput = {
|
||||
annee: number;
|
||||
brutHorsAstreinteReferenceCents: number;
|
||||
brutAvecPrimesReferenceCents: number;
|
||||
primeAstreinteCents: number;
|
||||
primeCents: number;
|
||||
primeReferenceCents: number;
|
||||
evolutionCents: number;
|
||||
evolutionReferenceCents: number;
|
||||
tauxChargeBps: number | null;
|
||||
};
|
||||
|
||||
export type AnnualProjection = {
|
||||
brutHorsAstreinteCents: number;
|
||||
brutAvecPrimesCents: number;
|
||||
brutMensuelHorsAstreinteCents: number;
|
||||
primeAstreinteCents: number;
|
||||
primeComplementaireCents: number;
|
||||
evolutionCents: number;
|
||||
tauxChargeBps: number | null;
|
||||
};
|
||||
|
||||
export type PayrollMonthlyInput = {
|
||||
annee: number;
|
||||
mois: number;
|
||||
matricule: string;
|
||||
brutMensuelCents: number;
|
||||
brutAvecPrimesCents: number;
|
||||
primeAstreinteCents: number;
|
||||
};
|
||||
|
||||
export type PayrollAnnualActual = Omit<AnnualProjection, "evolutionCents" | "tauxChargeBps"> & {
|
||||
moisDisponibles: number[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Restitue exactement la référence importée, puis applique seulement les
|
||||
* écarts manuels des primes et évolutions. Ainsi, un import Excel conserve ses
|
||||
* calculs initiaux tandis que toute correction autorisée est automatiquement
|
||||
* répercutée sur le brut prévisionnel dérivé.
|
||||
*/
|
||||
export function buildAnnualForecasts(inputs: AnnualForecastInput[]): Map<number, AnnualProjection> {
|
||||
const forecasts = new Map<number, AnnualProjection>();
|
||||
for (const current of [...inputs].sort((a, b) => a.annee - b.annee)) {
|
||||
const adjustmentDelta =
|
||||
(current.primeCents - current.primeReferenceCents)
|
||||
+ (current.evolutionCents - current.evolutionReferenceCents);
|
||||
const brutHorsAstreinteCents = current.brutHorsAstreinteReferenceCents + adjustmentDelta;
|
||||
const brutAvecPrimesCents = current.brutAvecPrimesReferenceCents + adjustmentDelta;
|
||||
forecasts.set(current.annee, {
|
||||
brutHorsAstreinteCents,
|
||||
brutAvecPrimesCents,
|
||||
brutMensuelHorsAstreinteCents: Math.round(brutHorsAstreinteCents / 12),
|
||||
primeAstreinteCents: current.primeAstreinteCents,
|
||||
primeComplementaireCents: current.primeCents,
|
||||
evolutionCents: current.evolutionCents,
|
||||
tauxChargeBps: current.tauxChargeBps,
|
||||
});
|
||||
}
|
||||
return forecasts;
|
||||
}
|
||||
|
||||
/**
|
||||
* Agrège les bulletins archivés par salarié et année. Le brut hors astreinte
|
||||
* ne retire que les primes d'astreinte : les autres éléments compris dans le
|
||||
* brut (activité, responsabilité, vacances…) sont donc préservés.
|
||||
*/
|
||||
export function buildAnnualPayrollActuals(inputs: PayrollMonthlyInput[]): Map<string, Map<number, PayrollAnnualActual>> {
|
||||
const grouped = new Map<string, Map<number, PayrollMonthlyInput[]>>();
|
||||
for (const input of inputs) {
|
||||
const byYear = grouped.get(input.matricule) ?? new Map<number, PayrollMonthlyInput[]>();
|
||||
byYear.set(input.annee, [...(byYear.get(input.annee) ?? []), input]);
|
||||
grouped.set(input.matricule, byYear);
|
||||
}
|
||||
|
||||
const actuals = new Map<string, Map<number, PayrollAnnualActual>>();
|
||||
for (const [matricule, byYear] of Array.from(grouped.entries())) {
|
||||
const projections = new Map<number, PayrollAnnualActual>();
|
||||
for (const [annee, monthlyRows] of Array.from(byYear.entries())) {
|
||||
const brutAvecPrimesCents = monthlyRows.reduce((sum, row) => sum + row.brutAvecPrimesCents, 0);
|
||||
const primeAstreinteCents = monthlyRows.reduce((sum, row) => sum + row.primeAstreinteCents, 0);
|
||||
const brutHorsAstreinteCents = brutAvecPrimesCents - primeAstreinteCents;
|
||||
const primeComplementaireCents = monthlyRows.reduce(
|
||||
(sum, row) => sum + (row.brutAvecPrimesCents - row.brutMensuelCents - row.primeAstreinteCents),
|
||||
0,
|
||||
);
|
||||
const moisDisponibles = Array.from(new Set<number>(monthlyRows.map((row) => row.mois))).sort((a, b) => a - b);
|
||||
projections.set(annee, {
|
||||
brutHorsAstreinteCents,
|
||||
brutAvecPrimesCents,
|
||||
brutMensuelHorsAstreinteCents: Math.round(brutHorsAstreinteCents / moisDisponibles.length),
|
||||
primeAstreinteCents,
|
||||
primeComplementaireCents,
|
||||
moisDisponibles,
|
||||
});
|
||||
}
|
||||
actuals.set(matricule, projections);
|
||||
}
|
||||
return actuals;
|
||||
}
|
||||
|
||||
/** Les ajustements de prévision sont bloqués sur les exercices clôturés. */
|
||||
export function isForecastAdjustmentEditable(annee: number, currentYear: number): boolean {
|
||||
return annee >= currentYear;
|
||||
}
|
||||
68
server/masseSalarialeWorkbookImport.test.ts
Normal file
68
server/masseSalarialeWorkbookImport.test.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { parseMasseSalarialeWorkbookRows } from "./masseSalarialeWorkbookImport";
|
||||
|
||||
describe("import du modèle Masse salariale", () => {
|
||||
it("importe les prévisionnels sans confondre taux, primes et évolutions", () => {
|
||||
const rows: Array<Array<string | number | Date | null>> = Array.from({ length: 5 }, () => []);
|
||||
rows.push([
|
||||
null,
|
||||
"000001",
|
||||
"NOM",
|
||||
"PRENOM",
|
||||
"Poste",
|
||||
new Date("2024-01-01T00:00:00.000Z"),
|
||||
35_000,
|
||||
37_000,
|
||||
2_000,
|
||||
2_916.67,
|
||||
0.45,
|
||||
1_000,
|
||||
36_000,
|
||||
39_000,
|
||||
2_500,
|
||||
3_000,
|
||||
0.46,
|
||||
1_500,
|
||||
40_000,
|
||||
43_000,
|
||||
2_000,
|
||||
3_333.33,
|
||||
null,
|
||||
2_000,
|
||||
0.5,
|
||||
]);
|
||||
|
||||
const parsed = parseMasseSalarialeWorkbookRows(rows);
|
||||
|
||||
expect(parsed.salaries).toHaveLength(1);
|
||||
expect(parsed.remunerations).toHaveLength(3);
|
||||
expect(parsed.remunerations.find((row) => row.annee === 2026)).toMatchObject({
|
||||
salaireAnnuelBrutHorsPrimesCents: 3_600_000,
|
||||
salaireAnnuelBrutAvecPrimesCents: 3_900_000,
|
||||
primeAstreinteCents: 250_000,
|
||||
evolutionCents: 150_000,
|
||||
tauxChargeBps: 4_600,
|
||||
primeCents: 0,
|
||||
});
|
||||
expect(parsed.remunerations.find((row) => row.annee === 2027)).toMatchObject({
|
||||
primeCents: 50,
|
||||
evolutionCents: 200_000,
|
||||
tauxChargeBps: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("accepte les lignes SheetJS sans cellule A vide et les dates Excel numériques", () => {
|
||||
const rows: Array<Array<string | number | Date | null>> = Array.from({ length: 5 }, () => []);
|
||||
rows.push([
|
||||
"000002", "NOM", "PRENOM", "Poste", 45_292,
|
||||
35_000, 37_000, 2_000, 2_916.67, 0.45, 1_000,
|
||||
36_000, 39_000, 2_500, 3_000, 0.46, 1_500,
|
||||
40_000, 43_000, 2_000, 3_333.33, null, 2_000, 0.5,
|
||||
]);
|
||||
|
||||
const parsed = parseMasseSalarialeWorkbookRows(rows);
|
||||
|
||||
expect(parsed.salaries).toMatchObject([{ matricule: "000002", dateEmbauche: "2024-01-01" }]);
|
||||
expect(parsed.remunerations).toHaveLength(3);
|
||||
});
|
||||
});
|
||||
118
server/masseSalarialeWorkbookImport.ts
Normal file
118
server/masseSalarialeWorkbookImport.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
import type { MasseSalarialeImportRemuneration, MasseSalarialeImportSalarie } from "./db";
|
||||
import XLSX from "xlsx";
|
||||
|
||||
type SpreadsheetValue = string | number | Date | null | undefined;
|
||||
|
||||
function cents(value: SpreadsheetValue): number {
|
||||
if (typeof value === "number") return Math.round(value * 100);
|
||||
if (typeof value !== "string") return 0;
|
||||
const normalized = value.replace(/[\s\u00a0]/g, "").replace(",", ".");
|
||||
const parsed = Number(normalized);
|
||||
return Number.isFinite(parsed) ? Math.round(parsed * 100) : 0;
|
||||
}
|
||||
|
||||
function bps(value: SpreadsheetValue): number | null {
|
||||
if (value === null || value === undefined || value === "") return null;
|
||||
const numeric = typeof value === "number" ? value : Number(String(value).replace(",", "."));
|
||||
return Number.isFinite(numeric) ? Math.round(numeric * 10_000) : null;
|
||||
}
|
||||
|
||||
function isoDate(value: SpreadsheetValue): string | null {
|
||||
if (value instanceof Date && !Number.isNaN(value.valueOf())) return value.toISOString().slice(0, 10);
|
||||
if (typeof value === "number" && Number.isFinite(value)) {
|
||||
const excelDate = new Date(Date.UTC(1899, 11, 30) + Math.round(value * 86_400_000));
|
||||
return Number.isNaN(excelDate.valueOf()) ? null : excelDate.toISOString().slice(0, 10);
|
||||
}
|
||||
if (typeof value === "string" && /^\d{4}-\d{2}-\d{2}$/.test(value)) return value;
|
||||
if (typeof value === "string" && /^\d{2}\/\d{2}\/\d{4}$/.test(value)) {
|
||||
const [day, month, year] = value.split("/");
|
||||
return `${year}-${month}-${day}`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function text(value: SpreadsheetValue): string {
|
||||
return typeof value === "string" || typeof value === "number" ? String(value).trim() : "";
|
||||
}
|
||||
|
||||
export type MasseSalarialeWorkbookImport = {
|
||||
salaries: MasseSalarialeImportSalarie[];
|
||||
remunerations: MasseSalarialeImportRemuneration[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Convertit les lignes de la feuille modèle (index Excel 1, en-tête sur la
|
||||
* ligne 5) sans évaluer de formules côté application : les valeurs calculées
|
||||
* et sauvegardées par le fichier fourni servent de référence importée.
|
||||
*/
|
||||
export function parseMasseSalarialeWorkbookRows(rows: SpreadsheetValue[][]): MasseSalarialeWorkbookImport {
|
||||
const salaries: MasseSalarialeImportSalarie[] = [];
|
||||
const remunerations: MasseSalarialeImportRemuneration[] = [];
|
||||
|
||||
for (const row of rows.slice(5)) {
|
||||
// SheetJS supprime la cellule A vide de certaines lignes. On repère donc
|
||||
// la présence effective de cette cellule plutôt que de supposer un index.
|
||||
const originalColumn = (excelColumn: number) => row[excelColumn + (row[0] === null || row[0] === undefined ? 0 : -1)];
|
||||
const matricule = text(originalColumn(1));
|
||||
const nom = text(originalColumn(2));
|
||||
const prenom = text(originalColumn(3));
|
||||
const poste = text(originalColumn(4));
|
||||
const dateEmbauche = isoDate(originalColumn(5));
|
||||
if (!matricule || !nom || !prenom || !poste || !dateEmbauche) continue;
|
||||
|
||||
salaries.push({ matricule, nom, prenom, poste, dateEmbauche });
|
||||
const addForecast = (annee: number, startIndex: number, tauxChargeBps: number | null, periodeReference: string) => {
|
||||
const brutHorsAstreinte = cents(originalColumn(startIndex));
|
||||
const brutAvecPrimes = cents(originalColumn(startIndex + 1));
|
||||
const primeAstreinte = cents(originalColumn(startIndex + 2));
|
||||
const brutMensuel = cents(originalColumn(startIndex + 3));
|
||||
const evolution = cents(originalColumn(startIndex + 5));
|
||||
const prime = annee === 2027 ? cents(originalColumn(startIndex + 6)) : 0;
|
||||
if (brutHorsAstreinte === 0 && brutAvecPrimes === 0 && brutMensuel === 0) return;
|
||||
|
||||
remunerations.push({
|
||||
matricule,
|
||||
annee,
|
||||
nature: "previsionnel",
|
||||
salaireAnnuelBrutHorsPrimesCents: brutHorsAstreinte,
|
||||
salaireAnnuelBrutAvecPrimesCents: brutAvecPrimes,
|
||||
salaireMensuelBrutHorsPrimesCents: brutMensuel,
|
||||
tauxChargeBps,
|
||||
primeAstreinteCents: primeAstreinte,
|
||||
primeCents: prime,
|
||||
evolutionCents: evolution,
|
||||
primeReferenceCents: prime,
|
||||
evolutionReferenceCents: evolution,
|
||||
periodeReference,
|
||||
statut: annee === 2025 ? "annuel" : "previsionnel",
|
||||
source: "fichier_modele_2026-08-28",
|
||||
});
|
||||
};
|
||||
|
||||
addForecast(2025, 6, bps(originalColumn(10)), "2025-12");
|
||||
addForecast(2026, 12, bps(originalColumn(16)), "2026-08");
|
||||
addForecast(2027, 18, null, "2027-01");
|
||||
}
|
||||
|
||||
return { salaries, remunerations };
|
||||
}
|
||||
|
||||
/**
|
||||
* Lit le fichier exclusivement côté serveur, afin que les données de masse
|
||||
* salariale ne soient ni transformées ni renvoyées par le navigateur.
|
||||
*/
|
||||
export function parseMasseSalarialeWorkbookBuffer(buffer: Buffer): MasseSalarialeWorkbookImport {
|
||||
const workbook = XLSX.read(buffer, { type: "buffer", cellDates: true });
|
||||
const sheet = workbook.Sheets["Masse salariale"];
|
||||
if (!sheet) throw new Error("Feuille Masse salariale introuvable");
|
||||
const rows = XLSX.utils.sheet_to_json<SpreadsheetValue[]>(sheet, {
|
||||
header: 1,
|
||||
raw: true,
|
||||
defval: null,
|
||||
});
|
||||
const parsed = parseMasseSalarialeWorkbookRows(rows);
|
||||
if (parsed.salaries.length === 0 || parsed.remunerations.length === 0) {
|
||||
throw new Error("Le fichier ne contient aucune prévision exploitable");
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
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;
|
||||
}
|
||||
38
server/payrollStorage.test.ts
Normal file
38
server/payrollStorage.test.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
const originalStorageDir = process.env.PAYROLL_STORAGE_DIR;
|
||||
const originalForgeUrl = process.env.BUILT_IN_FORGE_API_URL;
|
||||
const originalForgeKey = process.env.BUILT_IN_FORGE_API_KEY;
|
||||
|
||||
afterEach(async () => {
|
||||
vi.restoreAllMocks();
|
||||
if (originalStorageDir === undefined) delete process.env.PAYROLL_STORAGE_DIR;
|
||||
else process.env.PAYROLL_STORAGE_DIR = originalStorageDir;
|
||||
if (originalForgeUrl === undefined) delete process.env.BUILT_IN_FORGE_API_URL;
|
||||
else process.env.BUILT_IN_FORGE_API_URL = originalForgeUrl;
|
||||
if (originalForgeKey === undefined) delete process.env.BUILT_IN_FORGE_API_KEY;
|
||||
else process.env.BUILT_IN_FORGE_API_KEY = originalForgeKey;
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
describe("stockage persistant des bulletins", () => {
|
||||
it("conserve un PDF déjà chiffré dans le répertoire persistant quand Forge est indisponible", async () => {
|
||||
const storageDir = await mkdtemp(join(tmpdir(), "itinova-payroll-storage-"));
|
||||
process.env.PAYROLL_STORAGE_DIR = storageDir;
|
||||
delete process.env.BUILT_IN_FORGE_API_URL;
|
||||
delete process.env.BUILT_IN_FORGE_API_KEY;
|
||||
vi.stubGlobal("fetch", vi.fn(() => { throw new Error("Forge ne doit pas être appelé"); }));
|
||||
|
||||
const { payrollStorageGetBuffer, payrollStoragePut } = await import("./storage");
|
||||
const ciphertext = Buffer.from("octets-chiffres-de-test", "utf8");
|
||||
const stored = await payrollStoragePut("salaires/test.pdf.enc", ciphertext, "application/octet-stream");
|
||||
|
||||
expect(stored.key).toMatch(/^salaires\/test\.pdf_[a-f0-9]{8}\.enc$/);
|
||||
await expect(payrollStorageGetBuffer(stored.key)).resolves.toEqual(ciphertext);
|
||||
|
||||
await rm(storageDir, { recursive: true, force: true });
|
||||
});
|
||||
});
|
||||
@@ -8,6 +8,13 @@ import { sdk } from "./_core/sdk";
|
||||
import { systemRouter } from "./_core/systemRouter";
|
||||
import { adminProcedure, protectedProcedure, publicProcedure, router } from "./_core/trpc";
|
||||
import { isOpexEtablissementCode, normalizeOpexEtablissementCode } from "../shared/opexValidation";
|
||||
import { getPayrollEncryptionStatus } from "./payrollCrypto";
|
||||
import {
|
||||
buildAnnualForecasts,
|
||||
buildAnnualPayrollActuals,
|
||||
isForecastAdjustmentEditable,
|
||||
} from "./masseSalarialeProjection";
|
||||
import type { AnnualProjection } from "./masseSalarialeProjection";
|
||||
|
||||
const writeProcedure = protectedProcedure.use(({ ctx, next }) => {
|
||||
if (ctx.user.role === "readonly") {
|
||||
@@ -22,6 +29,35 @@ const opexEtablissementCodeSchema = z
|
||||
.transform(normalizeOpexEtablissementCode)
|
||||
.refine(isOpexEtablissementCode, { message: "Code établissement OPEX invalide" });
|
||||
|
||||
const salaireFiltersSchema = z.object({
|
||||
annee: z.number().int().min(2020).max(2100).optional(),
|
||||
mois: z.number().int().min(1).max(12).optional(),
|
||||
});
|
||||
|
||||
function periodKey(annee: number, mois: number) {
|
||||
return annee * 12 + (mois - 1);
|
||||
}
|
||||
|
||||
function describeMonthlySalaryDifference(
|
||||
current: { brutMensuelCents: number; brutAvecPrimesCents: number; primeAstreinteCents: number },
|
||||
previous: { brutMensuelCents: number; brutAvecPrimesCents: number; primeAstreinteCents: number } | undefined,
|
||||
) {
|
||||
if (!previous) return "Aucun bulletin de ce salarié disponible au mois précédent.";
|
||||
const difference = current.brutAvecPrimesCents - previous.brutAvecPrimesCents;
|
||||
if (difference === 0) return "Brut stable par rapport au mois précédent.";
|
||||
|
||||
const sign = (amount: number) => amount > 0 ? "hausse" : "baisse";
|
||||
const centsLabel = (amount: number) => `${(Math.abs(amount) / 100).toLocaleString("fr-FR", { minimumFractionDigits: 2, maximumFractionDigits: 2 })} €`;
|
||||
const reasons: string[] = [];
|
||||
const baseDifference = current.brutMensuelCents - previous.brutMensuelCents;
|
||||
const astreinteDifference = current.primeAstreinteCents - previous.primeAstreinteCents;
|
||||
const otherDifference = difference - baseDifference - astreinteDifference;
|
||||
if (baseDifference !== 0) reasons.push(`${sign(baseDifference)} de la base (${centsLabel(baseDifference)})`);
|
||||
if (astreinteDifference !== 0) reasons.push(`${sign(astreinteDifference)} des astreintes (${centsLabel(astreinteDifference)})`);
|
||||
if (otherDifference !== 0) reasons.push(`${sign(otherDifference)} d'autres éléments inclus au brut (${centsLabel(otherDifference)})`);
|
||||
return reasons.length > 0 ? reasons.join(" ; ") : "Variation du brut à contrôler.";
|
||||
}
|
||||
|
||||
export const appRouter = router({
|
||||
system: systemRouter,
|
||||
|
||||
@@ -248,10 +284,159 @@ export const appRouter = router({
|
||||
}),
|
||||
}),
|
||||
|
||||
/** Informations de disponibilité du stockage salarial, sans divulgation de secret. */
|
||||
salaires: router({
|
||||
getSecurityStatus: adminProcedure.query(() => getPayrollEncryptionStatus()),
|
||||
|
||||
periods: adminProcedure.query(async () => {
|
||||
const { liasses } = await db.getSalairesMensuels();
|
||||
return liasses.map((liasse) => ({
|
||||
id: liasse.id,
|
||||
annee: liasse.annee,
|
||||
mois: liasse.mois,
|
||||
nomFichier: liasse.nomFichier,
|
||||
tailleOctets: liasse.tailleOctets,
|
||||
statutExtraction: liasse.statutExtraction,
|
||||
erreurExtraction: liasse.erreurExtraction,
|
||||
importeLe: liasse.importeLe,
|
||||
}));
|
||||
}),
|
||||
|
||||
/**
|
||||
* Renvoie exclusivement l'index métier des bulletins, jamais les références
|
||||
* de stockage, empreintes ou paramètres cryptographiques.
|
||||
*/
|
||||
list: adminProcedure.input(salaireFiltersSchema).query(async ({ input }) => {
|
||||
const { liasses, rows } = await db.getSalairesMensuels();
|
||||
const liassesByPeriod = new Map(liasses.map((liasse) => [periodKey(liasse.annee, liasse.mois), liasse]));
|
||||
const rowsByLiasse = new Map<number, Map<string, typeof rows[number]["bulletin"]>>();
|
||||
rows.forEach(({ liasse, bulletin }) => {
|
||||
const byEmployee = rowsByLiasse.get(liasse.id) ?? new Map();
|
||||
byEmployee.set(bulletin.matricule, bulletin);
|
||||
rowsByLiasse.set(liasse.id, byEmployee);
|
||||
});
|
||||
|
||||
const selectedLiasses = liasses.filter((liasse) =>
|
||||
(!input.annee || liasse.annee === input.annee) && (!input.mois || liasse.mois === input.mois),
|
||||
);
|
||||
const selectedLiasseIds = new Set(selectedLiasses.map((liasse) => liasse.id));
|
||||
const bulletins = rows
|
||||
.filter(({ liasse }) => selectedLiasseIds.has(liasse.id))
|
||||
.map(({ liasse, bulletin }) => {
|
||||
const previousLiasse = liassesByPeriod.get(periodKey(liasse.annee, liasse.mois) - 1);
|
||||
const previous = previousLiasse ? rowsByLiasse.get(previousLiasse.id)?.get(bulletin.matricule) : undefined;
|
||||
const ecartBrutCents = previous ? bulletin.brutAvecPrimesCents - previous.brutAvecPrimesCents : null;
|
||||
return {
|
||||
liasseId: liasse.id,
|
||||
annee: liasse.annee,
|
||||
mois: liasse.mois,
|
||||
matricule: bulletin.matricule,
|
||||
nom: bulletin.nom,
|
||||
prenom: bulletin.prenom,
|
||||
poste: bulletin.poste,
|
||||
brutMensuelCents: bulletin.brutMensuelCents,
|
||||
brutAvecPrimesCents: bulletin.brutAvecPrimesCents,
|
||||
primeAstreinteCents: bulletin.primeAstreinteCents,
|
||||
brutPrecedentCents: previous?.brutAvecPrimesCents ?? null,
|
||||
ecartBrutCents,
|
||||
ecartBrutPourcentage: previous && previous.brutAvecPrimesCents !== 0
|
||||
? Math.round((ecartBrutCents! / previous.brutAvecPrimesCents) * 10_000) / 100
|
||||
: null,
|
||||
explication: describeMonthlySalaryDifference(bulletin, previous),
|
||||
numeroPage: bulletin.numeroPage,
|
||||
};
|
||||
});
|
||||
const totalBrutCents = bulletins.reduce((total, bulletin) => total + bulletin.brutAvecPrimesCents, 0);
|
||||
const totalEcartCents = bulletins.reduce((total, bulletin) => total + (bulletin.ecartBrutCents ?? 0), 0);
|
||||
return {
|
||||
liasses: selectedLiasses.map((liasse) => ({
|
||||
id: liasse.id,
|
||||
annee: liasse.annee,
|
||||
mois: liasse.mois,
|
||||
nomFichier: liasse.nomFichier,
|
||||
tailleOctets: liasse.tailleOctets,
|
||||
statutExtraction: liasse.statutExtraction,
|
||||
erreurExtraction: liasse.erreurExtraction,
|
||||
importeLe: liasse.importeLe,
|
||||
pdfPath: `/api/salaires/liasses/${liasse.id}/pdf`,
|
||||
})),
|
||||
bulletins,
|
||||
indicateurs: { totalBrutCents, totalEcartCents, nombreSalaries: bulletins.length },
|
||||
};
|
||||
}),
|
||||
}),
|
||||
|
||||
// Les données de rémunération sont sensibles : aucune procédure de ce
|
||||
// sous-routeur n'est accessible aux profils standard ou lecture seule.
|
||||
masseSalariale: router({
|
||||
get: adminProcedure.query(async () => db.getMasseSalariale()),
|
||||
get: adminProcedure.query(async () => {
|
||||
const source = await db.getMasseSalariale();
|
||||
const currentYear = new Date().getFullYear();
|
||||
const forecastsBySalarie = new Map<number, Map<number, AnnualProjection>>();
|
||||
|
||||
for (const remuneration of source.remunerations) {
|
||||
const projection = buildAnnualForecasts([{
|
||||
annee: remuneration.annee,
|
||||
brutHorsAstreinteReferenceCents: remuneration.salaireAnnuelBrutHorsPrimesCents,
|
||||
brutAvecPrimesReferenceCents: remuneration.salaireAnnuelBrutAvecPrimesCents,
|
||||
primeAstreinteCents: remuneration.primeAstreinteCents,
|
||||
primeCents: remuneration.primeCents,
|
||||
primeReferenceCents: remuneration.primeReferenceCents,
|
||||
evolutionCents: remuneration.evolutionCents,
|
||||
evolutionReferenceCents: remuneration.evolutionReferenceCents,
|
||||
tauxChargeBps: remuneration.tauxChargeBps,
|
||||
}]).get(remuneration.annee);
|
||||
if (!projection) continue;
|
||||
const byYear = forecastsBySalarie.get(remuneration.salarieId) ?? new Map();
|
||||
byYear.set(remuneration.annee, projection);
|
||||
forecastsBySalarie.set(remuneration.salarieId, byYear);
|
||||
}
|
||||
|
||||
const payrollActuals = buildAnnualPayrollActuals(source.bulletinsMensuels ?? []);
|
||||
const forecasts = source.remunerations.flatMap((remuneration) => {
|
||||
const projection = forecastsBySalarie.get(remuneration.salarieId)?.get(remuneration.annee);
|
||||
if (!projection) return [];
|
||||
return [{
|
||||
salarieId: remuneration.salarieId,
|
||||
annee: remuneration.annee,
|
||||
...projection,
|
||||
primeCents: remuneration.primeCents,
|
||||
evolutionCents: remuneration.evolutionCents,
|
||||
editable: isForecastAdjustmentEditable(remuneration.annee, currentYear),
|
||||
}];
|
||||
});
|
||||
const actuals: Array<AnnualProjection & {
|
||||
salarieId: number;
|
||||
annee: number;
|
||||
moisDisponibles: number[];
|
||||
origine: "liasses" | "cloture_2025";
|
||||
}> = [];
|
||||
for (const salarie of source.salaries) {
|
||||
const forecast2025 = forecastsBySalarie.get(salarie.id)?.get(2025);
|
||||
const archivedActuals = payrollActuals.get(salarie.matricule);
|
||||
for (const [annee, actual] of Array.from(archivedActuals?.entries() ?? [])) {
|
||||
actuals.push({
|
||||
salarieId: salarie.id,
|
||||
annee,
|
||||
...actual,
|
||||
origine: "liasses" as const,
|
||||
evolutionCents: 0,
|
||||
tauxChargeBps: null,
|
||||
});
|
||||
}
|
||||
if (forecast2025) {
|
||||
actuals.push({
|
||||
salarieId: salarie.id,
|
||||
annee: 2025,
|
||||
...forecast2025,
|
||||
moisDisponibles: Array.from({ length: 12 }, (_, index) => index + 1),
|
||||
origine: "cloture_2025" as const,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return { salaries: source.salaries, forecasts, actuals, currentYear };
|
||||
}),
|
||||
|
||||
import: adminProcedure
|
||||
.input(z.object({
|
||||
@@ -268,7 +453,13 @@ export const appRouter = router({
|
||||
salaireAnnuelBrutHorsPrimesCents: z.number().int().min(0).max(100_000_000),
|
||||
salaireAnnuelBrutAvecPrimesCents: z.number().int().min(0).max(100_000_000),
|
||||
salaireMensuelBrutHorsPrimesCents: z.number().int().min(0).max(10_000_000),
|
||||
tauxChargeBps: z.number().int().min(0).max(100_000),
|
||||
tauxChargeBps: z.number().int().min(0).max(100_000).nullable(),
|
||||
nature: z.literal("previsionnel").default("previsionnel"),
|
||||
primeAstreinteCents: z.number().int().min(-100_000_000).max(100_000_000).default(0),
|
||||
primeCents: z.number().int().min(-100_000_000).max(100_000_000).default(0),
|
||||
evolutionCents: z.number().int().min(-100_000_000).max(100_000_000).default(0),
|
||||
primeReferenceCents: z.number().int().min(-100_000_000).max(100_000_000).default(0),
|
||||
evolutionReferenceCents: z.number().int().min(-100_000_000).max(100_000_000).default(0),
|
||||
periodeReference: z.string().regex(/^\d{4}-\d{2}$/).nullable().optional(),
|
||||
statut: z.enum(["annuel", "cumul_provisoire"]),
|
||||
source: z.string().trim().min(1).max(64),
|
||||
@@ -284,9 +475,27 @@ export const appRouter = router({
|
||||
evolutionSalarialeBps: z.number().int().min(-10_000).max(10_000),
|
||||
}))
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
if (!isForecastAdjustmentEditable(input.annee, new Date().getFullYear())) {
|
||||
throw new TRPCError({ code: "FORBIDDEN", message: "Les exercices antérieurs ne sont pas modifiables" });
|
||||
}
|
||||
await db.setMasseSalarialeEvolution({ ...input, createdBy: ctx.user.id });
|
||||
return { success: true };
|
||||
}),
|
||||
|
||||
setForecastAdjustments: adminProcedure
|
||||
.input(z.object({
|
||||
salarieId: z.number().int().positive(),
|
||||
annee: z.number().int().min(2000).max(2100),
|
||||
primeCents: z.number().int().min(-100_000_000).max(100_000_000),
|
||||
evolutionCents: z.number().int().min(-100_000_000).max(100_000_000),
|
||||
}))
|
||||
.mutation(async ({ input }) => {
|
||||
if (!isForecastAdjustmentEditable(input.annee, new Date().getFullYear())) {
|
||||
throw new TRPCError({ code: "FORBIDDEN", message: "Les exercices antérieurs ne sont pas modifiables" });
|
||||
}
|
||||
await db.setMasseSalarialeForecastAdjustments(input);
|
||||
return { success: true };
|
||||
}),
|
||||
}),
|
||||
|
||||
inventaire: 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", () => ({ payrollStoragePut: vi.fn(), payrollStorageGetBuffer: 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 { payrollStorageGetBuffer, payrollStoragePut } 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(payrollStoragePut).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 });
|
||||
(payrollStoragePut 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 });
|
||||
(payrollStorageGetBuffer 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 });
|
||||
(payrollStorageGetBuffer 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(payrollStoragePut).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 { payrollStorageGetBuffer, payrollStoragePut } 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 payrollStoragePut(
|
||||
`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 payrollStorageGetBuffer(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 payrollStorageGetBuffer(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." });
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -2,6 +2,8 @@
|
||||
// Uploads via Forge Server presigned URL to S3 (PUT direct).
|
||||
// Downloads return /manus-storage/{key} paths served via 307 redirect.
|
||||
|
||||
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
||||
import { dirname, relative, resolve } from "node:path";
|
||||
import { ENV } from "./_core/env";
|
||||
|
||||
function getForgeConfig() {
|
||||
@@ -95,3 +97,55 @@ export async function storageGetSignedUrl(relKey: string): Promise<string> {
|
||||
const { url } = (await resp.json()) as { url: string };
|
||||
return url;
|
||||
}
|
||||
|
||||
/**
|
||||
* Télécharge un objet uniquement depuis le serveur. Cette primitive est utilisée
|
||||
* par la consultation de bulletins afin de ne jamais communiquer de lien de
|
||||
* stockage ni de document chiffré au navigateur.
|
||||
*/
|
||||
export async function storageGetBuffer(relKey: string): Promise<Buffer> {
|
||||
const signedUrl = await storageGetSignedUrl(relKey);
|
||||
const response = await fetch(signedUrl, { redirect: "follow" });
|
||||
if (!response.ok) throw new Error(`Storage download failed (${response.status})`);
|
||||
return Buffer.from(await response.arrayBuffer());
|
||||
}
|
||||
|
||||
/**
|
||||
* Répertoire persistant optionnel réservé aux PDF de paie déjà chiffrés.
|
||||
* Il n'est utilisé que sur les environnements sans configuration Forge, où le
|
||||
* volume Docker hôte survit aux reconstructions de l'image applicative.
|
||||
*/
|
||||
function getPayrollStoragePath(relKey: string): string | null {
|
||||
const configuredDirectory = process.env.PAYROLL_STORAGE_DIR?.trim();
|
||||
if (!configuredDirectory) return null;
|
||||
|
||||
const root = resolve(configuredDirectory);
|
||||
const destination = resolve(root, normalizeKey(relKey));
|
||||
const pathFromRoot = relative(root, destination);
|
||||
if (pathFromRoot.startsWith("..") || pathFromRoot === "") {
|
||||
throw new Error("Invalid payroll storage key");
|
||||
}
|
||||
return destination;
|
||||
}
|
||||
|
||||
export async function payrollStoragePut(
|
||||
relKey: string,
|
||||
data: Buffer | Uint8Array | string,
|
||||
contentType = "application/octet-stream",
|
||||
): Promise<{ key: string; url: string }> {
|
||||
const localKey = appendHashSuffix(normalizeKey(relKey));
|
||||
const localDestination = getPayrollStoragePath(localKey);
|
||||
if (!localDestination) return storagePut(relKey, data, contentType);
|
||||
|
||||
await mkdir(dirname(localDestination), { recursive: true, mode: 0o700 });
|
||||
await writeFile(localDestination, data, { mode: 0o600, flag: "wx" });
|
||||
// Les appels paie n'exposent jamais cette URL : la lecture passe exclusivement
|
||||
// par la route administrateur qui déchiffre côté serveur.
|
||||
return { key: localKey, url: `/manus-storage/${localKey}` };
|
||||
}
|
||||
|
||||
export async function payrollStorageGetBuffer(relKey: string): Promise<Buffer> {
|
||||
const localDestination = getPayrollStoragePath(relKey);
|
||||
if (!localDestination) return storageGetBuffer(relKey);
|
||||
return readFile(localDestination);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
98
todo.md
98
todo.md
@@ -138,5 +138,99 @@
|
||||
|
||||
## Correctif de fiabilité des logos de recette
|
||||
- [x] Remplacer les logos de stockage indisponibles par des wordmarks sans dépendance externe
|
||||
- [ ] Publier le correctif de wordmarks sur Gitea recette
|
||||
- [ ] Vérifier visuellement la page de connexion de recette après publication
|
||||
- [x] Publier le correctif de wordmarks sur Gitea recette
|
||||
- [x] Vérifier visuellement la page de connexion de recette après publication
|
||||
|
||||
## Export Excel Masse salariale
|
||||
- [x] Isoler, dans les bulletins, les seules primes d'astreinte à déduire du brut
|
||||
- [x] Ajouter une colonne Astreintes à déduire modifiable pour chaque année
|
||||
- [x] Calculer le brut hors primes par formule : brut annuel avec primes moins astreintes à déduire
|
||||
- [x] Générer un tableau Excel 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
|
||||
- [x] Intégrer toutes les semaines à cheval explicitement confirmées par les bulletins et vérifier les montants annuels — écarté par la règle finale lundi-lundi complète, qui exclut les segments à cheval entre exercices
|
||||
- [x] Intégrer la semaine du 29/12/2025 de Jérôme Pascuzzi confirmée par le bulletin de janvier 2026 — écartée par la même règle lundi-lundi complète appliquée au classeur livré
|
||||
- [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
|
||||
- [x] 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
|
||||
- [x] Pousser la fenêtre Salaires vers Gitea et déclencher la CI/CD de recette
|
||||
- [x] Vérifier l'application Salaires déployée en HTTPS sur la recette
|
||||
- [x] Configurer la clé AES des bulletins dans l'environnement sécurisé de recette et recréer le conteneur
|
||||
- [x] Transmettre explicitement PAYROLL_ENCRYPTION_KEY au conteneur applicatif de recette
|
||||
- [x] Vérifier la disponibilité du stockage persistant chiffré dans l'environnement de recette
|
||||
- [x] Stocker les PDF déjà chiffrés sur le volume persistant de recette si Forge n'est pas configuré
|
||||
- [x] Valider en recette l'import, l'index et la consultation d'une liasse réelle sans exposer de données de paie
|
||||
- [x] Appliquer et enregistrer les migrations Salaires dans la base de recette avant l'import mensuel
|
||||
|
||||
## Exécution en arrière-plan
|
||||
- [x] 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
|
||||
- [x] Vérifier visuellement la page Salaires de recette dans une session administrateur : navigation, filtres, liste et statuts
|
||||
- [x] Ouvrir une liasse depuis le bouton de l'interface de recette afin de valider le parcours utilisateur de consultation
|
||||
- [x] Corriger l'alerte frontale indiquant à tort un chiffrement indisponible en recette
|
||||
- [x] Corriger l'accès à la base de recette empêchant la connexion administrateur après le dernier redéploiement
|
||||
|
||||
## Promotion Salaires — recette et production
|
||||
- [x] Vérifier la version Salaires active et le statut Gitea de recette avant promotion
|
||||
- [x] Mettre à jour le dashboard et le portail de recette pour la fenêtre Salaires
|
||||
- [x] Promouvoir la version Salaires validée vers la production depuis le dépôt Gitea via le flux Santinova de déploiement sécurisé
|
||||
- [x] Mettre à jour le dashboard et le portail de production pour la fenêtre Salaires
|
||||
- [x] Vérifier les accès HTTPS administrateur et le statut des déploiements dans les deux environnements
|
||||
- [x] Paramétrer le domaine Traefik Budget SI par environnement pour que la production réponde sur son URL dédiée
|
||||
- [x] Corriger le visuel Budget SI non chargé sur la carte du portail de recette
|
||||
- [x] Créer et synchroniser le dépôt Gitea production Budget SI avant le déploiement du serveur de production
|
||||
- [x] Initialiser et vérifier le schéma de la base Budget SI de production avant la validation administrateur
|
||||
- [x] Exécuter les migrations Drizzle au démarrage du conteneur pour les futurs déploiements de production
|
||||
- [x] Créer le compte administrateur initial requis lorsque la base Budget SI est vide
|
||||
- [x] Synchroniser le correctif d'initialisation de la base vers le dépôt et le serveur de production
|
||||
- [x] Corriger la connexion administrateur de recette restée en attente après le redéploiement du bootstrap
|
||||
- [x] Diagnostiquer puis redémarrer les services d'infrastructure de recette autorisés pour rétablir l'API et le dashboard
|
||||
|
||||
## Masse salariale — prévisionnel et réel annuel
|
||||
- [x] Analyser la structure et les formules du fichier modèle Masse salariale fourni
|
||||
- [x] Importer les données du fichier comme réel et prévisionnel 2025, ainsi que prévisionnel 2026 et 2027
|
||||
- [x] Calculer le réel 2026 exclusivement à partir des liasses mensuelles importées dans Salaires
|
||||
- [x] Ajouter les colonnes annuelles prévisionnel et réel, avec les calculs automatiques requis
|
||||
- [x] Autoriser uniquement l'édition administrateur des primes et évolutions pour l'année en cours ou future
|
||||
- [x] Tester les règles de calcul, de données réelles et d'autorisation de modification
|
||||
- [ ] Déployer l'évolution validée en recette puis en production
|
||||
- [x] Afficher les centimes des montants de masse salariale lorsque le fichier modèle en contient
|
||||
- [x] Reprendre le brut annuel avec primes calculé par le fichier modèle comme référence prévisionnelle exacte
|
||||
- [ ] Synchroniser le fichier prévisionnel et les liasses mensuelles nécessaires au réel 2026 en recette et production
|
||||
- [x] Archiver les liasses de janvier à avril 2026 fournies dans Salaires avant le calcul réel annuel
|
||||
- [x] Ajouter un téléversement administrateur du fichier Excel de référence dans Masse salariale
|
||||
|
||||
Reference in New Issue
Block a user