Checkpoint: Ajout de la projection annuelle prévisionnel-réel dans Masse salariale : import contrôlé du fichier modèle 2025-2027, réel 2026 provenant uniquement des liasses Salaires, ajustements administrateur limités aux primes et évolutions de l'année courante/future, conservation des centimes, modèle de calcul testé et documentation. 72 tests, TypeScript et build validés.

This commit is contained in:
Manus
2026-08-31 10:58:02 +00:00
parent 5c9618b204
commit 28570234d0
17 changed files with 2415 additions and 204 deletions

View File

@@ -1,261 +1,179 @@
// 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 { Fragment, useMemo, useState } from "react";
import { AppSidebar } from '../components/AppSidebar'; import { AppSidebar } from "../components/AppSidebar";
import { MasseSalarialeDataState } from '../components/MasseSalarialeDataState'; import { MasseSalarialeDataState } from "../components/MasseSalarialeDataState";
import { trpc } from '../lib/trpc'; import { trpc } from "../lib/trpc";
import { getMasseSalarialeUiState, getMasseSalarialeYearSummary } from '../lib/masseSalariale'; import { getMasseSalarialeUiState } from "../lib/masseSalariale";
import { toast } from 'sonner'; import { toast } from "sonner";
import { import { Banknote, CalendarDays, CircleAlert, LockKeyhole, ShieldCheck, Users } from "lucide-react";
Banknote,
CalendarDays,
CircleAlert,
ShieldCheck,
Users,
} from 'lucide-react';
const ANNEES = [2025, 2026, 2027] as const; const ANNEES = [2025, 2026, 2027] as const;
function formatEuros(cents?: number | null) { function formatEuros(cents?: number | null) {
if (cents === undefined || cents === null) return '—'; if (cents === undefined || cents === null) return "—";
return new Intl.NumberFormat('fr-FR', { return new Intl.NumberFormat("fr-FR", {
style: 'currency', style: "currency",
currency: 'EUR', currency: "EUR",
maximumFractionDigits: 0, minimumFractionDigits: cents % 100 === 0 ? 0 : 2,
maximumFractionDigits: 2,
}).format(cents / 100); }).format(cents / 100);
} }
function formatPercent(bps?: number | null) { function formatPercent(bps?: number | null) {
if (bps === undefined || bps === null) return '—'; if (bps === undefined || bps === null) return "—";
return `${(bps / 100).toLocaleString('fr-FR', { maximumFractionDigits: 2 })} %`; return `${(bps / 100).toLocaleString("fr-FR", { maximumFractionDigits: 2 })} %`;
} }
function formatDate(isoDate?: string | null) { function formatDate(isoDate?: string | null) {
if (!isoDate) return '—'; if (!isoDate) return "—";
const [year, month, day] = isoDate.split('-'); const [year, month, day] = isoDate.split("-");
return year && month && day ? `${day}/${month}/${year}` : isoDate; return year && month && day ? `${day}/${month}/${year}` : isoDate;
} }
function evolutionKey(salarieId: number, annee: number) { function keyFor(salarieId: number, annee: number) {
return `${salarieId}-${annee}`; 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 * Les prévisions viennent du classeur de référence. Le réel n'est affiché que
* importés. L'évolution, elle, reste une hypothèse utilisateur distincte et * lorsqu'il provient d'une clôture ou d'une liasse PDF effectivement archivée.
* n'engendre donc jamais un montant 2027 artificiel.
*/ */
export default function MasseSalariale() { export default function MasseSalariale() {
const [sidebarCollapsed, setSidebarCollapsed] = useState(false); const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
const [editingKey, setEditingKey] = useState<string | null>(null); const [editingKey, setEditingKey] = useState<string | null>(null);
const [evolutionDraft, setEvolutionDraft] = useState(''); const [primeDraft, setPrimeDraft] = useState("");
const [evolutionDraft, setEvolutionDraft] = useState("");
const utils = trpc.useUtils(); const utils = trpc.useUtils();
const { data, isLoading, isError } = trpc.masseSalariale.get.useQuery(); const { data, isLoading, isError } = trpc.masseSalariale.get.useQuery();
const setEvolution = trpc.masseSalariale.setEvolution.useMutation({ const saveAdjustments = trpc.masseSalariale.setForecastAdjustments.useMutation({
onSuccess: async () => { onSuccess: async () => {
await utils.masseSalariale.get.invalidate(); await utils.masseSalariale.get.invalidate();
setEditingKey(null); setEditingKey(null);
setEvolutionDraft(''); toast.success("Prévision mise à jour");
toast.success('Évolution salariale enregistrée');
}, },
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( const forecastByKey = useMemo(
() => new Map((data?.remunerations ?? []).map((row) => [evolutionKey(row.salarieId, row.annee), row])), () => new Map((data?.forecasts ?? []).map((row) => [keyFor(row.salarieId, row.annee), row])),
[data?.remunerations], [data?.forecasts],
); );
const evolutionByKey = useMemo( const actualByKey = useMemo(
() => new Map((data?.evolutions ?? []).map((row) => [evolutionKey(row.salarieId, row.annee), row])), () => new Map((data?.actuals ?? []).map((row) => [keyFor(row.salarieId, row.annee), row])),
[data?.evolutions], [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 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 startEdit = (salarieId: number, annee: number) => {
const key = evolutionKey(salarieId, annee); const forecast = forecastByKey.get(keyFor(salarieId, annee));
const current = evolutionByKey.get(key)?.evolutionSalarialeBps; if (!forecast?.editable) return;
setEditingKey(key); setEditingKey(keyFor(salarieId, annee));
setEvolutionDraft(current === undefined || current === null ? '' : String(current / 100)); setPrimeDraft((forecast.primeCents / 100).toFixed(2));
setEvolutionDraft((forecast.evolutionCents / 100).toFixed(2));
}; };
const saveEvolution = (salarieId: number, annee: number) => { const saveEdit = (salarieId: number, annee: number) => {
const normalized = evolutionDraft.trim().replace(',', '.'); const normalize = (value: string) => Number(value.trim().replace(",", "."));
const percentage = Number(normalized); const prime = normalize(primeDraft);
if (!normalized || !Number.isFinite(percentage) || percentage < -100 || percentage > 100) { const evolution = normalize(evolutionDraft);
toast.error('Saisissez une évolution comprise entre -100 % et 100 %.'); 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; return;
} }
setEvolution.mutate({ salarieId, annee, evolutionSalarialeBps: Math.round(percentage * 100) }); saveAdjustments.mutate({
salarieId,
annee,
primeCents: Math.round(prime * 100),
evolutionCents: Math.round(evolution * 100),
});
}; };
return ( return (
<div className="min-h-screen flex bg-background"> <div className="min-h-screen flex bg-background">
<AppSidebar collapsed={sidebarCollapsed} onToggle={() => setSidebarCollapsed((value) => !value)} /> <AppSidebar collapsed={sidebarCollapsed} onToggle={() => setSidebarCollapsed((value) => !value)} />
<main className="flex-1 flex flex-col min-w-0 overflow-hidden"> <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"> <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-start justify-between gap-4">
<div className="flex items-center gap-3 min-w-0"> <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"> <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>
<Banknote className="w-5 h-5 text-emerald-600" />
</div>
<div className="min-w-0"> <div className="min-w-0">
<h1 className="text-xl font-bold text-foreground" style={{ fontFamily: 'Sora, sans-serif' }}> <h1 className="text-xl font-bold text-foreground" style={{ fontFamily: "Sora, sans-serif" }}>Masse salariale SANTINOVA</h1>
Masse salariale SANTINOVA <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>
</h1>
<p className="text-sm text-muted-foreground mt-0.5">
Rémunérations 20252027 et évolutions salariales manuelles
</p>
</div> </div>
</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"> <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>
<ShieldCheck className="w-3.5 h-3.5" />
Accès administrateur
</div>
</div> </div>
</header> </header>
<div className="flex-1 overflow-auto px-6 py-5 space-y-5"> <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"> <div className="grid grid-cols-1 xl:grid-cols-3 gap-3">
{ANNEES.map((annee) => { {ANNEES.map((annee) => {
const total = totalsByYear.get(annee); const total = totals.get(annee);
const isProvisional = total?.status === 'cumul_provisoire'; const hasActual = (total?.actualCount ?? 0) > 0;
return ( return <section key={annee} className="bg-card border border-border rounded-xl shadow-sm p-4">
<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="flex items-center justify-between gap-2"> <div className="grid grid-cols-2 gap-4 mt-3">
<div className="flex items-center gap-2 text-sm font-semibold text-foreground"> <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>
<CalendarDays className="w-4 h-4 text-emerald-600" /> <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>
{annee}
</div> </div>
<span className={`text-[10px] font-semibold uppercase tracking-wide px-2 py-0.5 rounded-full ${ </section>;
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>
);
})} })}
</div> </div>
<div className="bg-amber-50 border border-amber-200 rounded-xl px-4 py-3 flex gap-3 text-sm text-amber-900"> <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>
<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>
<section className="bg-card border border-border rounded-xl shadow-sm overflow-hidden"> <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="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>
<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>
<MasseSalarialeDataState state={uiState}> <MasseSalarialeDataState state={uiState}>
<div className="overflow-auto max-h-[calc(100vh-335px)]"> <div className="overflow-auto max-h-[calc(100vh-350px)]">
<table className="w-full min-w-[1900px] text-sm border-collapse"> <table className="w-full min-w-[4900px] text-sm border-collapse">
<thead className="sticky top-0 z-10 bg-muted/95 backdrop-blur"> <thead className="sticky top-0 z-10 bg-muted/95 backdrop-blur">
<tr className="border-b border-border"> <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>
<th rowSpan={2} className="sticky left-0 z-20 bg-muted/95 text-left px-4 py-3 font-semibold text-foreground">Salarié</th> <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>
<th rowSpan={2} className="text-left px-4 py-3 font-semibold text-foreground">Poste</th> <tr className="border-b border-border text-[10px] uppercase tracking-wide text-muted-foreground">{ANNEES.flatMap((annee) => [
<th rowSpan={2} className="text-left px-4 py-3 font-semibold text-foreground">Embauche</th> <th key={`${annee}-f-base`} className="px-3 py-2 text-right font-medium border-l border-border">Annuel hors astreinte</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>)} <th key={`${annee}-f-total`} className="px-3 py-2 text-right font-medium">Annuel avec primes</th>,
</tr> <th key={`${annee}-f-monthly`} className="px-3 py-2 text-right font-medium">Mensuel hors astreinte</th>,
<tr className="border-b border-border text-[10px] uppercase tracking-wide text-muted-foreground"> <th key={`${annee}-f-astreinte`} className="px-3 py-2 text-right font-medium">Astreintes</th>,
{ANNEES.flatMap((annee) => [ <th key={`${annee}-f-charges`} className="px-3 py-2 text-right font-medium">Taux charges</th>,
<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}-f-evolution`} className="px-3 py-2 text-right font-medium">Évolution</th>,
<th key={`${annee}-annual-total`} className="px-3 py-2 text-right font-medium">Annuel avec primes</th>, <th key={`${annee}-f-prime`} className="px-3 py-2 text-right font-medium">Prime</th>,
<th key={`${annee}-monthly`} className="px-3 py-2 text-right font-medium">Mensuel hors primes</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}-charges`} className="px-3 py-2 text-right font-medium">Taux charges</th>, <th key={`${annee}-a-total`} className="px-3 py-2 text-right font-medium">Cumul avec primes</th>,
<th key={`${annee}-evolution`} className="px-3 py-2 text-right font-medium">Évolution</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>,
</tr> <th key={`${annee}-a-months`} className="px-3 py-2 text-right font-medium">Mois</th>,
])}</tr>
</thead> </thead>
<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) => {
{data?.salaries.map((salarie) => ( const forecast = forecastByKey.get(keyFor(salarie.id, annee));
<tr key={salarie.id} className="border-b border-border/70 hover:bg-muted/30"> const actual = actualByKey.get(keyFor(salarie.id, annee));
<td className="sticky left-0 z-[1] bg-card px-4 py-3 min-w-[220px]"> const editing = editingKey === keyFor(salarie.id, annee);
<p className="font-semibold text-foreground">{salarie.nom} {salarie.prenom}</p> return <Fragment key={keyFor(salarie.id, annee)}>
<p className="text-[11px] text-muted-foreground font-mono">{salarie.matricule}</p> <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> <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-4 py-3 text-xs text-foreground min-w-[200px]">{salarie.poste}</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-4 py-3 text-xs text-muted-foreground whitespace-nowrap">{formatDate(salarie.dateEmbauche)}</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>
{ANNEES.map((annee) => { </Fragment>;
const remuneration = remunerationByKey.get(evolutionKey(salarie.id, annee)); })}</tr>)}</tbody>
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>
</table> </table>
</div> </div>
</MasseSalarialeDataState> </MasseSalarialeDataState>

View File

@@ -0,0 +1,35 @@
# 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.

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, "when": 1788041740554,
"tag": "0010_brainy_oracle", "tag": "0010_brainy_oracle",
"breakpoints": true "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(), id: int("id").autoincrement().primaryKey(),
salarieId: int("salarieId").notNull(), salarieId: int("salarieId").notNull(),
annee: int("annee").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(), salaireAnnuelBrutHorsPrimesCents: int("salaireAnnuelBrutHorsPrimesCents").notNull(),
salaireAnnuelBrutAvecPrimesCents: int("salaireAnnuelBrutAvecPrimesCents").notNull(), salaireAnnuelBrutAvecPrimesCents: int("salaireAnnuelBrutAvecPrimesCents").notNull(),
salaireMensuelBrutHorsPrimesCents: int("salaireMensuelBrutHorsPrimesCents").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. */ /** Mois de paie ayant servi de référence, ex. 2025-12 ou 2026-08. */
periodeReference: varchar("periodeReference", { length: 7 }), periodeReference: varchar("periodeReference", { length: 7 }),
/** `annuel` pour un exercice clos ; `cumul_provisoire` pour une année en cours. */ /** `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(), createdAt: timestamp("createdAt").defaultNow().notNull(),
updatedAt: timestamp("updatedAt").defaultNow().onUpdateNow().notNull(), updatedAt: timestamp("updatedAt").defaultNow().onUpdateNow().notNull(),
}, (t) => ({ }, (t) => ({
/** Rend les imports idempotents pour un salarié et un exercice. */ /** Rend les imports idempotents pour un salarié, exercice et nature de donnée. */
uniqSalarieAnnee: uniqueIndex("masse_salariale_salarie_annee_idx").on(t.salarieId, t.annee), uniqSalarieAnneeNature: uniqueIndex("masse_salariale_salarie_annee_nature_idx").on(t.salarieId, t.annee, t.nature),
})); }));
export type MasseSalarialeRemuneration = typeof masseSalarialeRemunerations.$inferSelect; 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

@@ -74,6 +74,7 @@ vi.mock("./db", async (importOriginal) => {
getMasseSalariale: vi.fn(), getMasseSalariale: vi.fn(),
importMasseSalariale: vi.fn(), importMasseSalariale: vi.fn(),
setMasseSalarialeEvolution: vi.fn(), setMasseSalarialeEvolution: vi.fn(),
setMasseSalarialeForecastAdjustments: vi.fn(),
getSalairesMensuels: vi.fn(), getSalairesMensuels: vi.fn(),
}; };
}); });
@@ -356,11 +357,16 @@ describe("masseSalariale", () => {
it("retourne les données uniquement pour un administrateur", async () => { it("retourne les données uniquement pour un administrateur", async () => {
const { db } = await import("./db").then(m => ({ db: m })); 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); (db.getMasseSalariale as ReturnType<typeof vi.fn>).mockResolvedValue(source);
const { ctx } = createAuthCtx({ role: "admin" }); 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 () => { it("interdit la consultation à un profil standard", async () => {
@@ -369,6 +375,43 @@ describe("masseSalariale", () => {
await expect(appRouter.createCaller(ctx).masseSalariale.get()).rejects.toThrow(); 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 () => { it("importe les données validées uniquement pour un administrateur", async () => {
const { db } = await import("./db").then(m => ({ db: m })); const { db } = await import("./db").then(m => ({ db: m }));
(db.importMasseSalariale as ReturnType<typeof vi.fn>).mockResolvedValue({ salaries: 1, remunerations: 1 }); (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 })) await expect(appRouter.createCaller(ctx).masseSalariale.import({ salaries, remunerations }))
.resolves.toEqual({ salaries: 1, remunerations: 1 }); .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 () => { it("interdit l'import de rémunérations à un profil standard", async () => {
@@ -434,6 +485,52 @@ describe("masseSalariale", () => {
})).rejects.toThrow(); })).rejects.toThrow();
expect(db.setMasseSalarialeEvolution).not.toHaveBeenCalled(); 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é ───────────────── // ─── 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 { drizzle } from "drizzle-orm/mysql2";
import { import {
capexLignes, capexLignes,
@@ -478,12 +478,20 @@ export type MasseSalarialeImportRemuneration = Omit<
export async function getMasseSalariale() { export async function getMasseSalariale() {
const db = await getDb(); const db = await getDb();
if (!db) throw new Error("Database not available"); 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(masseSalarialeSalaries).orderBy(asc(masseSalarialeSalaries.nom), asc(masseSalarialeSalaries.prenom)),
db.select().from(masseSalarialeRemunerations), db.select().from(masseSalarialeRemunerations),
db.select().from(masseSalarialeEvolutions), 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( export async function importMasseSalariale(
salaries: MasseSalarialeImportSalarie[], salaries: MasseSalarialeImportSalarie[],
remunerations: MasseSalarialeImportRemuneration[], remunerations: MasseSalarialeImportRemuneration[],
options: { replaceForecastYears?: number[] } = {},
) { ) {
const db = await getDb(); const db = await getDb();
if (!db) throw new Error("Database not available"); if (!db) throw new Error("Database not available");
@@ -520,6 +529,13 @@ export async function importMasseSalariale(
.from(masseSalarialeSalaries); .from(masseSalarialeSalaries);
const idsByMatricule = new Map(persistedSalaries.map((row) => [row.matricule, row.id])); 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) { for (const remuneration of remunerations) {
const salarieId = idsByMatricule.get(remuneration.matricule); const salarieId = idsByMatricule.get(remuneration.matricule);
if (!salarieId) { if (!salarieId) {
@@ -535,6 +551,12 @@ export async function importMasseSalariale(
salaireAnnuelBrutAvecPrimesCents: values.salaireAnnuelBrutAvecPrimesCents, salaireAnnuelBrutAvecPrimesCents: values.salaireAnnuelBrutAvecPrimesCents,
salaireMensuelBrutHorsPrimesCents: values.salaireMensuelBrutHorsPrimesCents, salaireMensuelBrutHorsPrimesCents: values.salaireMensuelBrutHorsPrimesCents,
tauxChargeBps: values.tauxChargeBps, tauxChargeBps: values.tauxChargeBps,
nature: values.nature,
primeAstreinteCents: values.primeAstreinteCents,
primeCents: values.primeCents,
evolutionCents: values.evolutionCents,
primeReferenceCents: values.primeReferenceCents,
evolutionReferenceCents: values.evolutionReferenceCents,
periodeReference: values.periodeReference, periodeReference: values.periodeReference,
statut: values.statut, statut: values.statut,
source: values.source, 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. */ /** Sauvegarde l'évolution manuelle sans dépendre de l'existence d'un bulletin. */
export async function setMasseSalarialeEvolution( export async function setMasseSalarialeEvolution(
input: Pick<InsertMasseSalarialeEvolution, "salarieId" | "annee" | "evolutionSalarialeBps" | "createdBy">, input: Pick<InsertMasseSalarialeEvolution, "salarieId" | "annee" | "evolutionSalarialeBps" | "createdBy">,

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,97 @@
import type { MasseSalarialeImportRemuneration, MasseSalarialeImportSalarie } from "./db";
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 };
}

View File

@@ -9,6 +9,12 @@ import { systemRouter } from "./_core/systemRouter";
import { adminProcedure, protectedProcedure, publicProcedure, router } from "./_core/trpc"; import { adminProcedure, protectedProcedure, publicProcedure, router } from "./_core/trpc";
import { isOpexEtablissementCode, normalizeOpexEtablissementCode } from "../shared/opexValidation"; import { isOpexEtablissementCode, normalizeOpexEtablissementCode } from "../shared/opexValidation";
import { getPayrollEncryptionStatus } from "./payrollCrypto"; import { getPayrollEncryptionStatus } from "./payrollCrypto";
import {
buildAnnualForecasts,
buildAnnualPayrollActuals,
isForecastAdjustmentEditable,
} from "./masseSalarialeProjection";
import type { AnnualProjection } from "./masseSalarialeProjection";
const writeProcedure = protectedProcedure.use(({ ctx, next }) => { const writeProcedure = protectedProcedure.use(({ ctx, next }) => {
if (ctx.user.role === "readonly") { 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 // Les données de rémunération sont sensibles : aucune procédure de ce
// sous-routeur n'est accessible aux profils standard ou lecture seule. // sous-routeur n'est accessible aux profils standard ou lecture seule.
masseSalariale: router({ masseSalariale: router({
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 import: adminProcedure
.input(z.object({ .input(z.object({
@@ -380,7 +453,13 @@ export const appRouter = router({
salaireAnnuelBrutHorsPrimesCents: z.number().int().min(0).max(100_000_000), salaireAnnuelBrutHorsPrimesCents: z.number().int().min(0).max(100_000_000),
salaireAnnuelBrutAvecPrimesCents: 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), 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(), periodeReference: z.string().regex(/^\d{4}-\d{2}$/).nullable().optional(),
statut: z.enum(["annuel", "cumul_provisoire"]), statut: z.enum(["annuel", "cumul_provisoire"]),
source: z.string().trim().min(1).max(64), 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), evolutionSalarialeBps: z.number().int().min(-10_000).max(10_000),
})) }))
.mutation(async ({ input, ctx }) => { .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 }); await db.setMasseSalarialeEvolution({ ...input, createdBy: ctx.user.id });
return { success: true }; 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({ inventaire: router({

13
todo.md
View File

@@ -220,3 +220,16 @@
- [x] Synchroniser le correctif d'initialisation de la base vers le dépôt et le serveur de production - [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] 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 - [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