Compare commits

...

3 Commits

21 changed files with 2605 additions and 208 deletions

View File

@@ -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 20252027 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 20252027, sans modifier les liasses de paie ni les réels déjà archivés.</p></div></div>
<div className="flex flex-col sm:flex-row gap-2 sm:items-center"><input aria-label="Fichier Excel de masse salariale" type="file" accept=".xlsx,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" onChange={(event) => setWorkbookFile(event.target.files?.[0] ?? null)} disabled={isImportingWorkbook} className="max-w-72 text-xs file:mr-3 file:rounded-md file:border-0 file:bg-emerald-50 file:px-3 file:py-2 file:text-xs file:font-semibold file:text-emerald-700 hover:file:bg-emerald-100" /><button onClick={importWorkbook} disabled={!workbookFile || isImportingWorkbook} className="inline-flex items-center justify-center gap-2 rounded-lg bg-emerald-600 px-3 py-2 text-xs font-semibold text-white hover:bg-emerald-700 disabled:cursor-not-allowed disabled:opacity-50"><Upload className="w-3.5 h-3.5" />{isImportingWorkbook ? "Import en cours…" : "Importer le fichier"}</button></div>
</section>
<div className="grid grid-cols-1 xl:grid-cols-3 gap-3">
{ANNEES.map((annee) => {
const total = 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 nest 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 dastreinte. Les cellules vertes <strong>Prime</strong> et <strong>Évolution</strong> sont modifiables seulement pour {data?.currentYear ?? "lexercice 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>

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

View File

@@ -51,3 +51,17 @@ La promotion vers la production utilise le domaine HTTPS `budget-si.santinova-so
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.

View 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`);

File diff suppressed because it is too large Load Diff

View File

@@ -78,6 +78,13 @@
"when": 1788041740554,
"tag": "0010_brainy_oracle",
"breakpoints": true
},
{
"idx": 11,
"version": "5",
"when": 1788172234921,
"tag": "0011_dashing_grey_gargoyle",
"breakpoints": true
}
]
}

View File

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

View 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}")

View 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),
}));

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

View File

@@ -7,6 +7,7 @@ import { registerOAuthRoutes } from "./oauth";
import { registerStorageProxy } from "./storageProxy";
import { appRouter } from "../routers";
import { registerSalairesHttpRoutes } from "../salairesHttp";
import { registerMasseSalarialeHttpRoutes } from "../masseSalarialeHttp";
import { runDatabaseMigrations } from "../databaseMigrations";
import { createContext } from "./context";
import { serveStatic, setupVite } from "./vite";
@@ -41,6 +42,7 @@ async function startServer() {
registerStorageProxy(app);
registerOAuthRoutes(app);
registerSalairesHttpRoutes(app);
registerMasseSalarialeHttpRoutes(app);
// tRPC API
app.use(
"/api/trpc",

View File

@@ -74,6 +74,7 @@ vi.mock("./db", async (importOriginal) => {
getMasseSalariale: vi.fn(),
importMasseSalariale: vi.fn(),
setMasseSalarialeEvolution: vi.fn(),
setMasseSalarialeForecastAdjustments: vi.fn(),
getSalairesMensuels: vi.fn(),
};
});
@@ -356,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 () => {
@@ -369,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 });
@@ -394,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 () => {
@@ -434,6 +485,52 @@ 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é ─────────────────

View File

@@ -1,4 +1,4 @@
import { and, asc, desc, eq } from "drizzle-orm";
import { and, asc, desc, eq, inArray } from "drizzle-orm";
import { drizzle } from "drizzle-orm/mysql2";
import {
capexLignes,
@@ -478,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 };
}
/**
@@ -494,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");
@@ -520,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) {
@@ -535,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,
@@ -545,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">,

View File

@@ -0,0 +1,58 @@
import express from "express";
import request from "supertest";
import { beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("./_core/sdk", () => ({ sdk: { authenticateRequest: vi.fn() } }));
vi.mock("./db", () => ({ importMasseSalariale: vi.fn() }));
vi.mock("./masseSalarialeWorkbookImport", () => ({ parseMasseSalarialeWorkbookBuffer: vi.fn() }));
import { sdk } from "./_core/sdk";
import { importMasseSalariale } from "./db";
import { registerMasseSalarialeHttpRoutes } from "./masseSalarialeHttp";
import { parseMasseSalarialeWorkbookBuffer } from "./masseSalarialeWorkbookImport";
function makeApp() {
const app = express();
registerMasseSalarialeHttpRoutes(app);
return app;
}
describe("route HTTP d'import Masse salariale", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("interdit l'import Excel à un profil non administrateur", async () => {
(sdk.authenticateRequest as ReturnType<typeof vi.fn>).mockResolvedValue({ id: 2, role: "standard" });
await request(makeApp())
.post("/api/masse-salariale/previsionnels")
.attach("file", Buffer.from("PK\x03\x04 fichier Excel"), { filename: "masse.xlsx", contentType: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" })
.expect(403);
expect(importMasseSalariale).not.toHaveBeenCalled();
});
it("importe un fichier Excel administrateur sans transmettre son contenu au client", async () => {
(sdk.authenticateRequest as ReturnType<typeof vi.fn>).mockResolvedValue({ id: 1, role: "admin" });
(parseMasseSalarialeWorkbookBuffer as ReturnType<typeof vi.fn>).mockReturnValue({
salaries: [{ matricule: "000001", nom: "NOM", prenom: "PRENOM", poste: "Poste", dateEmbauche: "2024-01-01" }],
remunerations: [{
matricule: "000001", annee: 2026, nature: "previsionnel", salaireAnnuelBrutHorsPrimesCents: 100_000,
salaireAnnuelBrutAvecPrimesCents: 110_000, salaireMensuelBrutHorsPrimesCents: 8_333,
tauxChargeBps: 4_500, primeAstreinteCents: 10_000, primeCents: 0, primeReferenceCents: 0,
evolutionCents: 0, evolutionReferenceCents: 0, periodeReference: "2026-08", statut: "previsionnel", source: "fichier_modele_2026-08-28",
}],
});
(importMasseSalariale as ReturnType<typeof vi.fn>).mockResolvedValue({ salaries: 1, remunerations: 1 });
const response = await request(makeApp())
.post("/api/masse-salariale/previsionnels")
.attach("file", Buffer.from("PK\x03\x04 fichier Excel"), { filename: "masse.xlsx", contentType: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" })
.expect(200);
expect(importMasseSalariale).toHaveBeenCalledWith(expect.any(Array), expect.any(Array), { replaceForecastYears: [2025, 2026, 2027] });
expect(response.body).toEqual({ salaries: 1, remunerations: 1, years: [2025, 2026, 2027] });
expect(JSON.stringify(response.body)).not.toContain("000001");
});
});

View File

@@ -0,0 +1,52 @@
import type { Express, Request, Response } from "express";
import multer from "multer";
import { sdk } from "./_core/sdk";
import { importMasseSalariale } from "./db";
import { parseMasseSalarialeWorkbookBuffer } from "./masseSalarialeWorkbookImport";
const MAX_WORKBOOK_BYTES = 5 * 1024 * 1024;
const upload = multer({ storage: multer.memoryStorage(), limits: { fileSize: MAX_WORKBOOK_BYTES, files: 1 } });
async function getAdminUser(req: Request, res: Response) {
try {
const user = await sdk.authenticateRequest(req);
if (user.role === "admin") return user;
res.status(403).json({ error: "Accès réservé aux administrateurs." });
} catch {
res.status(401).json({ error: "Authentification requise." });
}
return null;
}
function isXlsx(file: Express.Multer.File | undefined): file is Express.Multer.File {
if (!file) return false;
return file.originalname.toLowerCase().endsWith(".xlsx")
&& file.buffer.subarray(0, 2).toString("ascii") === "PK";
}
/**
* Cette route est volontairement limitée au transfert binaire du classeur.
* Elle ne renvoie jamais les lignes salariales : la page relit ensuite son
* agrégat tRPC administrateur.
*/
export function registerMasseSalarialeHttpRoutes(app: Express) {
app.post("/api/masse-salariale/previsionnels", upload.single("file"), async (req, res) => {
const user = await getAdminUser(req, res);
if (!user) return;
if (!isXlsx(req.file)) {
res.status(400).json({ error: "Transmettez un fichier .xlsx conforme (5 Mo maximum)." });
return;
}
try {
const parsed = parseMasseSalarialeWorkbookBuffer(req.file.buffer);
const result = await importMasseSalariale(parsed.salaries, parsed.remunerations, {
replaceForecastYears: [2025, 2026, 2027],
});
res.json({ salaries: result.salaries, remunerations: result.remunerations, years: [2025, 2026, 2027] });
} catch (error) {
console.error("[Masse salariale] Échec import prévisionnel", error instanceof Error ? error.message : "erreur inconnue");
res.status(422).json({ error: "Le fichier n'a pas pu être importé. Les prévisions existantes sont conservées." });
}
});
}

View File

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

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

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

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

View File

@@ -9,6 +9,12 @@ 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") {
@@ -363,7 +369,74 @@ export const appRouter = router({
// 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({
@@ -380,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),
@@ -396,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({

25
todo.md
View File

@@ -210,10 +210,27 @@
- [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
- [ ] Vérifier les accès HTTPS administrateur et le statut des déploiements dans les deux environnements
- [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
- [ ] Initialiser et vérifier le schéma de la base Budget SI de production avant la validation administrateur
- [ ] Exécuter les migrations Drizzle au démarrage du conteneur pour les futurs déploiements de production
- [ ] Créer le compte administrateur initial requis lorsque la base Budget SI est vide
- [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