Compare commits

...

12 Commits

Author SHA1 Message Date
Manus
8312cf25fc Checkpoint: Masse salariale enrichie : import Excel administrateur côté serveur, projections annuelles 2025-2027 importées du fichier de référence, réel 2026 exclusivement calculé sur les liasses archivées, conservation des montants de référence et des centimes, éditions limitées aux primes et évolutions de l'exercice courant/futur. 74 tests, TypeScript et build validés.
All checks were successful
Validation applicative / TypeScript, tests et build (push) Successful in 5m21s
2026-08-31 11:04:30 +00:00
Manus
28570234d0 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. 2026-08-31 10:58:02 +00:00
Manus
5c9618b204 Checkpoint: Déploiements Budget SI finalisés : recette remise en service après redémarrage contrôlé et CI Gitea réussie ; production synchronisée depuis Gitea, migrations automatiques et compte administrateur initial validés ; portails et dashboards recette/production mis à jour. Les contrôles HTTPS administrateur et Salaires sont consignés. 2026-08-30 15:53:25 +00:00
Manus
f759032793 Checkpoint: Correctif de première installation : après les migrations Drizzle, le serveur crée le compte administrateur Itinova requis avec mot de passe haché, sans doublon. Validation complète réussie : 62 tests, TypeScript et build.
All checks were successful
Validation applicative / TypeScript, tests et build (push) Successful in 1h3m35s
2026-08-30 09:54:50 +00:00
Manus
b2c8580382 Checkpoint: Correctif de production : le serveur exécute désormais les migrations Drizzle versionnées avant l'ouverture HTTP, ce qui initialise une base vide lors du premier déploiement. Test de régression ajouté. Validation complète réussie : 61 tests, TypeScript et build.
All checks were successful
Validation applicative / TypeScript, tests et build (push) Successful in 4m45s
2026-08-30 09:40:46 +00:00
Manus
6447389cb8 Checkpoint: Préparation recette-production : manifeste Budget SI aligné avec le registre dashboard, visuel de portail sélectionné, et label Traefik paramétrable par BUDGET_SI_DOMAIN afin que la production réponde sur son URL dédiée. Vérification complète réussie : 60 tests, TypeScript et build.
All checks were successful
Validation applicative / TypeScript, tests et build (push) Successful in 2m22s
2026-08-30 09:07:15 +00:00
Manus
7ee4e848d2 Checkpoint: Clôture Salaires : validation HTTPS administrateur réalisée après correction de la collision DNS MySQL. La page Salaires, les filtres, le statut d'extraction et l'ouverture du PDF via la route protégée sont vérifiés. Tous les contrôles projet sont tracés ; la version fonctionnelle eeac2de est déployée en recette via Gitea CI/CD. 2026-08-30 08:59:05 +00:00
Manus
eeac2de481 Checkpoint: Correctif recette : DATABASE_URL cible désormais le conteneur MySQL unique itinova-budget-si-db, évitant la collision de l'alias Docker partagé db qui empêchait la connexion administrateur après recréation. Test de régression Compose ajouté. Validation complète réussie : 59 tests, TypeScript, build et format Compose.
All checks were successful
Validation applicative / TypeScript, tests et build (push) Successful in 2m1s
2026-08-29 23:54:15 +00:00
Manus
0e59dac29f Checkpoint: Correctif UX Salaires : pendant le chargement de la sonde AES-256-GCM, l'interface affiche désormais une vérification neutre et désactive l'import au lieu d'afficher à tort une alerte de blocage. Test de régression ajouté. Validation complète réussie : 58 tests, TypeScript et build.
All checks were successful
Validation applicative / TypeScript, tests et build (push) Successful in 1m52s
2026-08-29 23:40:21 +00:00
Manus
193f7ce32a Checkpoint: Ajout du secours de stockage réservé aux PDF de paie chiffrés : lorsque Forge est indisponible en recette, les octets AES-256-GCM sont stockés dans un volume hôte Docker persistant, jamais dans l'image ni la base. Import, réindexation et consultation passent par cette même abstraction, avec test de round-trip sans Forge. Compose transmet la clé AES et monte le volume dédié. Validation complète : 56 tests, TypeScript, build et format YAML.
All checks were successful
Validation applicative / TypeScript, tests et build (push) Successful in 2m9s
2026-08-29 23:19:21 +00:00
Manus
7698e9f680 Checkpoint: Correctif d'environnement recette : docker-compose transmet désormais explicitement PAYROLL_ENCRYPTION_KEY au service applicatif. La clé AES est déjà configurée de façon sécurisée dans le fichier d'environnement recette, sans affichage de sa valeur. pnpm verify passe après cette correction : TypeScript, 55 tests et build.
All checks were successful
Validation applicative / TypeScript, tests et build (push) Successful in 1m50s
2026-08-29 23:02:19 +00:00
Manus
558582d358 Checkpoint: Correctif CI de recette : le test d'état de chiffrement Salaires utilise désormais une clé AES-256 de test locale et restaure l'environnement, au lieu de dépendre du secret PAYROLL_ENCRYPTION_KEY non injecté dans le runner Gitea. La commande exacte de CI pnpm verify est validée localement : TypeScript, 55 tests et build passent.
All checks were successful
Validation applicative / TypeScript, tests et build (push) Successful in 2m45s
2026-08-29 22:52:03 +00:00
34 changed files with 2951 additions and 260 deletions

View File

@@ -1,14 +1,14 @@
{
"id": "itinova-budget-si",
"name": "Gestion Budget Informatique",
"name": "Gestion budget informatique",
"category": "SANTINOVA",
"urls": {
"recette": "https://budget-si.recette.santinova-soft.org",
"prod": "https://budget-si.santinova-soft.org"
},
"containerName": "itinova-budget-si-app",
"image": "images/budget-si.png",
"giteaRepo": "itinova-budget-si",
"image": "images/budget-si.jpg",
"giteaRepo": "manus-admin/itinova-budget-si",
"giteaOwner": "manus-admin",
"ci": {
"required": true

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">
{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 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 = 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,18 @@
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it } from "vitest";
import { PayrollSecurityNotice } from "./Salaires";
describe("PayrollSecurityNotice", () => {
it("affiche un état neutre de vérification, et non une alerte, tant que la sonde de chiffrement charge", () => {
const html = renderToStaticMarkup(<PayrollSecurityNotice isLoading ready={undefined} />);
expect(html).toContain("Vérification du chiffrement sécurisé…");
expect(html).not.toContain("Import bloqué");
});
it("n'affiche l'alerte de blocage que lorsque la sonde a répondu indisponible", () => {
const html = renderToStaticMarkup(<PayrollSecurityNotice isLoading={false} ready={false} />);
expect(html).toContain("Import bloqué");
});
});

View File

@@ -61,7 +61,7 @@ export default function Salaires() {
const filters = useMemo(() => ({ annee, mois }), [annee, mois]);
const { data, isLoading, isError } = trpc.salaires.list.useQuery(filters);
const { data: periods = [] } = trpc.salaires.periods.useQuery();
const { data: security } = trpc.salaires.getSecurityStatus.useQuery();
const { data: security, isLoading: isSecurityLoading } = trpc.salaires.getSecurityStatus.useQuery();
const years = useMemo(() => {
const options = new Set([now.getFullYear(), ...periods.map((period) => period.annee)]);
@@ -77,7 +77,7 @@ export default function Salaires() {
toast.error("Le fichier sélectionné doit être un PDF.");
return;
}
if (!security?.ready) {
if (security?.ready !== true) {
toast.error("Le chiffrement sécurisé est indisponible : l'import est bloqué.");
return;
}
@@ -143,12 +143,7 @@ export default function Salaires() {
</header>
<div className="flex-1 overflow-auto px-6 py-5 space-y-5">
{!security?.ready && (
<section className="rounded-xl border border-rose-200 bg-rose-50 px-4 py-3 flex gap-3 text-sm text-rose-900">
<CircleAlert className="w-4 h-4 mt-0.5 shrink-0 text-rose-600" />
<p><strong>Import bloqué.</strong> Le chiffrement AES-256-GCM n'est pas disponible dans cet environnement. Aucun PDF n'est transmis sans chiffrement.</p>
</section>
)}
<PayrollSecurityNotice isLoading={isSecurityLoading} ready={security?.ready} />
<section className="bg-card border border-border rounded-xl shadow-sm p-4">
<div className="flex flex-wrap items-end gap-3">
@@ -185,7 +180,7 @@ export default function Salaires() {
<FileText className="w-6 h-6 text-emerald-600 shrink-0" />
<span className="min-w-0 flex-1"><strong className="block text-sm text-foreground truncate">{selectedFile?.name ?? "Choisir la liasse PDF"}</strong><span className="text-xs text-muted-foreground">PDF uniquement, 25 Mo maximum</span><input ref={inputRef} type="file" accept="application/pdf,.pdf" aria-label="Choisir la liasse PDF" onChange={(event) => setSelectedFile(event.target.files?.[0] ?? null)} className="mt-1.5 block max-w-full text-xs text-muted-foreground file:mr-3 file:rounded-md file:border-0 file:bg-emerald-600 file:px-2.5 file:py-1 file:text-xs file:font-semibold file:text-white hover:file:bg-emerald-700" /></span>
</div>
<button onClick={uploadLiasse} disabled={isUploading || hasExistingPeriod || !selectedFile || !security?.ready} className="h-10 inline-flex items-center justify-center gap-2 rounded-lg bg-emerald-600 px-4 text-sm font-semibold text-white hover:bg-emerald-700 disabled:cursor-not-allowed disabled:opacity-50 transition-colors active:scale-[0.97]">
<button onClick={uploadLiasse} disabled={isUploading || isSecurityLoading || hasExistingPeriod || !selectedFile || security?.ready !== true} className="h-10 inline-flex items-center justify-center gap-2 rounded-lg bg-emerald-600 px-4 text-sm font-semibold text-white hover:bg-emerald-700 disabled:cursor-not-allowed disabled:opacity-50 transition-colors active:scale-[0.97]">
{isUploading ? <Loader2 className="w-4 h-4 animate-spin" /> : <LockKeyhole className="w-4 h-4" />} {isUploading ? "Archivage…" : "Importer et chiffrer"}
</button>
</div>
@@ -224,3 +219,11 @@ function StatCard({ icon: Icon, label, value, accent }: { icon: React.ElementTyp
function DataNotice({ message }: { message: string }) {
return <div className="py-12 text-center text-sm text-muted-foreground"><CircleAlert className="w-5 h-5 mx-auto mb-2 text-muted-foreground/60" />{message}</div>;
}
export function PayrollSecurityNotice({ isLoading, ready }: { isLoading: boolean; ready: boolean | undefined }) {
if (isLoading) {
return <section className="rounded-xl border border-sky-200 bg-sky-50 px-4 py-3 flex gap-3 text-sm text-sky-900"><Loader2 className="w-4 h-4 mt-0.5 shrink-0 animate-spin text-sky-700" /><p><strong>Vérification du chiffrement sécurisé…</strong> L'import reste temporairement désactivé.</p></section>;
}
if (ready === true) return null;
return <section className="rounded-xl border border-rose-200 bg-rose-50 px-4 py-3 flex gap-3 text-sm text-rose-900"><CircleAlert className="w-4 h-4 mt-0.5 shrink-0 text-rose-600" /><p><strong>Import bloqué.</strong> Le chiffrement AES-256-GCM n'est pas disponible dans cet environnement. Aucun PDF n'est transmis sans chiffrement.</p></section>;
}

View File

@@ -5,7 +5,7 @@ services:
restart: unless-stopped
environment:
- NODE_ENV=production
- DATABASE_URL=mysql://budget_user:BudgetSI2027!@db:3306/budget_si
- DATABASE_URL=mysql://budget_user:BudgetSI2027!@itinova-budget-si-db:3306/budget_si
- JWT_SECRET=${JWT_SECRET:-budget-si-recette-jwt-secret-2027}
- VITE_APP_ID=${VITE_APP_ID:-}
- OAUTH_SERVER_URL=${OAUTH_SERVER_URL:-}
@@ -18,6 +18,10 @@ services:
- VITE_FRONTEND_FORGE_API_URL=${VITE_FRONTEND_FORGE_API_URL:-}
- VITE_ANALYTICS_ENDPOINT=${VITE_ANALYTICS_ENDPOINT:-}
- VITE_ANALYTICS_WEBSITE_ID=${VITE_ANALYTICS_WEBSITE_ID:-}
- PAYROLL_ENCRYPTION_KEY=${PAYROLL_ENCRYPTION_KEY:-}
- PAYROLL_STORAGE_DIR=/var/lib/itinova-budget-si/payroll
volumes:
- ${PAYROLL_STORAGE_HOST_DIR:-/opt/manus-deploy/data/itinova-budget-si/payroll}:/var/lib/itinova-budget-si/payroll
depends_on:
db:
condition: service_healthy
@@ -25,7 +29,7 @@ services:
- web
labels:
- "traefik.enable=true"
- "traefik.http.routers.budget-si.rule=Host(`budget-si.recette.santinova-soft.org`)"
- "traefik.http.routers.budget-si.rule=Host(`${BUDGET_SI_DOMAIN:-budget-si.recette.santinova-soft.org}`)"
- "traefik.http.routers.budget-si.entrypoints=websecure"
- "traefik.http.routers.budget-si.tls.certresolver=letsencrypt"
- "traefik.http.services.budget-si.loadbalancer.server.port=3000"
@@ -42,12 +46,23 @@ services:
volumes:
- db_data:/var/lib/mysql
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "root", "-pBudgetSI2027!"]
test:
[
"CMD",
"mysqladmin",
"ping",
"-h",
"localhost",
"-u",
"root",
"-pBudgetSI2027!",
]
interval: 10s
timeout: 5s
retries: 5
networks:
- web
volumes:
db_data:

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

@@ -2,7 +2,7 @@
Le module **Salaires** est réservé aux comptes de profil `admin`, à la fois dans la navigation, dans les routes clientes, dans les procédures métier et sur les deux routes binaires serveur. Les profils `standard` et `readonly` ne peuvent ni lister les données, ni importer, ni consulter une liasse.
Les fichiers PDF sont chiffrés côté serveur avec AES-256-GCM avant leur envoi vers le stockage persistant du projet. La base de données contient seulement les métadonnées indispensables, les paramètres cryptographiques par fichier et l'index métier minimal : matricule, identité professionnelle, poste et montants bruts. Elle ne contient ni adresse, ni IBAN, ni numéro de sécurité sociale, ni net à payer.
Les fichiers PDF sont chiffrés côté serveur avec AES-256-GCM avant leur stockage. Lorsque Forge est configuré, l'archive chiffrée est envoyée au stockage persistant du projet. En recette, où Forge n'est pas disponible, elle est stockée dans un volume Docker monté depuis `/opt/manus-deploy/data/itinova-budget-si/payroll` sur l'hôte : ce volume n'est ni intégré à l'image, ni supprimé par la reconstruction du conteneur. La base de données contient seulement les métadonnées indispensables, les paramètres cryptographiques par fichier et l'index métier minimal : matricule, identité professionnelle, poste et montants bruts. Elle ne contient ni adresse, ni IBAN, ni numéro de sécurité sociale, ni net à payer.
La consultation récupère le contenu chiffré exclusivement côté serveur, le déchiffre après contrôle de la session administrateur et retourne le PDF avec les entêtes `Cache-Control: no-store` et `X-Content-Type-Options: nosniff`. Le navigateur ne reçoit aucune URL de stockage ni paramètre de chiffrement.
@@ -23,3 +23,45 @@ Une seconde liasse réelle, pour juin 2026, a été archivée et indexée au mê
La consultation réelle de la liasse de juin a retourné un document dont la signature est `%PDF-`, avec `Content-Type: application/pdf`, `Cache-Control: no-store, private` et `X-Content-Type-Options: nosniff`. Aucune URL de stockage n'a été renvoyée au navigateur.
Après un redémarrage complet du serveur de développement, les deux archives mensuelles et leurs statuts d'indexation étaient toujours présents dans l'interface. La relecture de la liasse de juin a de nouveau retourné un PDF valide, avec l'entête `no-store`. Cette vérification confirme que le fichier chiffré est lu depuis le stockage persistant extérieur au processus applicatif, et non depuis le système de fichiers d'un conteneur.
La CI Gitea de recette et le déploiement Blue-Green ont été validés pour le commit qui transmet explicitement la clé de paie au conteneur applicatif. Le déploiement sauvegarde la base avant reconstruction, met à jour le code depuis Gitea, reconstruit l'image sans cache, puis recrée l'application une fois la base saine.
Après ce déploiement, une vérification de recette a créé un marqueur de contrôle dans le volume de paie, recréé uniquement le conteneur applicatif, puis relu le marqueur avec la même empreinte SHA-256. Le marqueur a été supprimé immédiatement après le contrôle. Cette vérification confirme la persistance effective du volume à travers une recréation Docker, sans y déposer de donnée salariale de test.
Le contrôle final de recette a ensuite importé une liasse réelle de mai 2026. L'import a créé une archive chiffrée et un index de 18 bulletins au statut `partiel`, sans fabrication des quatre fiches non reconnues. La liste tRPC filtrée retourne les 18 entrées et n'expose ni clé de stockage, ni IV, ni tag d'authentification, ni empreinte. La route PDF a retourné un document de 79 752 octets commençant par `%PDF-`, avec les en-têtes `Content-Type: application/pdf`, `Cache-Control: no-store, private` et `X-Content-Type-Options: nosniff`. Après une recréation Docker de l'application, la même archive était encore lisible, avec la même taille et la même signature PDF.
La vérification visuelle administrateur de recette a confirmé la présence de l'entrée **Salaires**, des filtres, de l'archive de mai et de son statut partiel. Une alerte frontale incohérente indiquant que le chiffrement était indisponible a toutefois été observée alors que la sonde serveur retournait bien `AES-256-GCM` opérationnel ; cette divergence doit être corrigée avant clôture.
Après le chargement des requêtes, le filtre de mai 2026 a affiché 18 bulletins indexés, un total brut mensuel et le statut partiel de la liasse, sans alerte de chiffrement. La divergence initiale correspond au rendu transitoire avant réception de la sonde de sécurité ; ce rendu doit être remplacé par un état de chargement neutre, jamais par une alerte de blocage.
Depuis la page authentifiée de recette, le bouton **Voir la liasse** a ouvert le visualiseur PDF du navigateur sur la route protégée de consultation. Le parcours interface utilise uniquement cette route applicative ; il ne présente aucune URL de stockage au navigateur.
Un diagnostic de recette a révélé qu'après une recréation Docker, l'alias réseau générique `db` pouvait résoudre vers une autre base présente sur le réseau partagé, provoquant un refus d'accès et empêchant temporairement toute connexion. La configuration utilise désormais le nom unique `itinova-budget-si-db`. Une sonde de régression vérifie ce point avant chaque déploiement.
La CI Gitea `validate.yml` associée au correctif de résolution MySQL a terminé avec succès. Le contrôle de déploiement final reste centré sur la reconnexion administrateur et la page Salaires en HTTPS après le redémarrage applicatif.
Après le déploiement du correctif, la connexion avec le compte administrateur de recette a de nouveau abouti et l'interface générale s'affiche sans erreur de base de données.
La validation finale en HTTPS a confirmé le menu **Santinova > Salaires**, l'absence de fausse alerte de chiffrement, l'affichage de la liasse archivée et l'ouverture de cette liasse exclusivement via le bouton de consultation protégé. Aucun lien de stockage n'est exposé dans l'interface.
Le portail de recette et le dashboard ont été synchronisés à partir du manifeste applicatif. La carte **Gestion budget informatique** est présente dans l'onglet Santinova du portail, utilise son visuel dédié et ouvre l'URL HTTPS de recette.
La promotion vers la production utilise le domaine HTTPS `budget-si.santinova-soft.org`, un secret de session et une clé de chiffrement distincts de la recette, ainsi qu'un volume persistant de paie dédié. La page de connexion de production a répondu après le déploiement initial.
Le dashboard de production a découvert Budget SI et le signale en ligne. Le portail de production présente désormais la carte **Gestion budget informatique** dans l'onglet Santinova, avec son visuel dédié et le lien HTTPS de production. La promotion a été réalisée depuis le dépôt Gitea production par le flux Santinova de déploiement sécurisé ; la CI Gitea reste validée sur la recette avant promotion.
La validation de connexion administrateur de production a mis en évidence une initialisation de schéma incomplète : la table des utilisateurs n'est pas encore disponible. Cette correction est requise avant de déclarer la production utilisable.
Le serveur de production a été redéployé depuis le dépôt Gitea après ajout du bootstrap de migrations et du compte administrateur initial. La page HTTPS de connexion est de nouveau accessible ; la validation authentifiée suit.
La connexion avec le compte administrateur initial a abouti après le redéploiement. Le menu d'administration et les modules Budget SI sont accessibles en production sur une base désormais initialisée.
Le parcours administrateur de production permet d'ouvrir **Santinova > Salaires**. La page présente les filtres mensuels, l'import PDF limité et les zones d'archives ; l'état du chiffrement est encore en vérification au premier affichage et fait l'objet du contrôle final.
La page HTTPS de connexion de recette est accessible après la synchronisation du bootstrap ; la validation administrateur de recette reste en cours.
La tentative de connexion administrateur de recette est restée en attente après le redéploiement. Le diagnostic de cette divergence, alors que la production est fonctionnelle, est en cours avant clôture.
Après redémarrage contrôlé du portail et du dashboard, puis relance du déploiement une fois la CI de recette terminée avec succès, la connexion administrateur de recette est de nouveau opérationnelle.
La validation finale de recette confirme l'accès administrateur à **Santinova > Salaires**, le statut de chiffrement opérationnel, l'archive mensuelle existante, son statut partiel explicite, ainsi que les commandes de réindexation et de consultation protégée.

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,8 @@ 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";
@@ -30,6 +32,8 @@ async function findAvailablePort(startPort: number = 3000): Promise<number> {
}
async function startServer() {
// Une instance ne devient disponible qu'après la synchronisation du schéma.
await runDatabaseMigrations();
const app = express();
const server = createServer(app);
// Configure body parser with larger size limit for file uploads
@@ -38,6 +42,7 @@ async function startServer() {
registerStorageProxy(app);
registerOAuthRoutes(app);
registerSalairesHttpRoutes(app);
registerMasseSalarialeHttpRoutes(app);
// tRPC API
app.use(
"/api/trpc",

View File

@@ -0,0 +1,22 @@
import { readFile } from "node:fs/promises";
import { resolve } from "node:path";
import { describe, expect, it } from "vitest";
describe("manifeste applicatif Budget SI", () => {
it("déclare les environnements, la catégorie et le dépôt Gitea attendus", async () => {
const raw = await readFile(resolve(process.cwd(), "app.json"), "utf8");
const manifest = JSON.parse(raw) as Record<string, unknown>;
expect(manifest).toMatchObject({
id: "itinova-budget-si",
name: "Gestion budget informatique",
category: "SANTINOVA",
giteaRepo: "manus-admin/itinova-budget-si",
image: "images/budget-si.jpg",
urls: {
recette: "https://budget-si.recette.santinova-soft.org",
prod: "https://budget-si.santinova-soft.org",
},
});
});
});

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é ─────────────────
@@ -441,9 +538,15 @@ describe("masseSalariale", () => {
describe("salaires.getSecurityStatus", () => {
it("valide la clé de chiffrement configurée via une procédure administrateur", async () => {
const { ctx } = createAuthCtx({ role: "admin" });
const previousKey = process.env.PAYROLL_ENCRYPTION_KEY;
process.env.PAYROLL_ENCRYPTION_KEY = Buffer.alloc(32, 9).toString("base64");
try {
await expect(appRouter.createCaller(ctx).salaires.getSecurityStatus())
.resolves.toEqual({ ready: true, algorithm: "AES-256-GCM" });
} finally {
if (previousKey === undefined) delete process.env.PAYROLL_ENCRYPTION_KEY;
else process.env.PAYROLL_ENCRYPTION_KEY = previousKey;
}
});
it("interdit la sonde de sécurité aux profils non administrateurs", async () => {

View File

@@ -0,0 +1,55 @@
import { migrate } from "drizzle-orm/mysql2/migrator";
import { drizzle } from "drizzle-orm/mysql2";
import { eq } from "drizzle-orm";
import { createPool } from "mysql2/promise";
import bcrypt from "bcryptjs";
import { users } from "../drizzle/schema";
const INITIAL_ADMIN_LOGIN = "adminItinova";
const INITIAL_ADMIN_PASSWORD = process.env.INITIAL_ADMIN_PASSWORD ?? "Itinova69!";
async function ensureInitialAdmin(pool: ReturnType<typeof createPool>) {
const database = drizzle(pool);
const existingAdmin = await database
.select({ id: users.id })
.from(users)
.where(eq(users.login, INITIAL_ADMIN_LOGIN))
.limit(1);
if (existingAdmin.length > 0) return;
const passwordHash = await bcrypt.hash(INITIAL_ADMIN_PASSWORD, 12);
await database.insert(users).values({
login: INITIAL_ADMIN_LOGIN,
email: "adminItinova@santinova-soft.org",
firstName: "Admin",
lastName: "Itinova",
passwordHash,
role: "admin",
isActive: true,
});
console.log("[Database] Compte administrateur initial créé.");
}
/**
* Synchronise la base avec les migrations versionnées avant d'exposer l'API.
* Une base sans DATABASE_URL reste utilisable en développement statique, mais
* un déploiement connecté échoue explicitement si la migration ne peut aboutir.
*/
export async function runDatabaseMigrations() {
const databaseUrl = process.env.DATABASE_URL;
if (!databaseUrl) {
console.warn("[Database] DATABASE_URL absent : migrations ignorées.");
return;
}
const pool = createPool(databaseUrl);
try {
const database = drizzle(pool);
await migrate(database, { migrationsFolder: "./drizzle" });
await ensureInitialAdmin(pool);
console.log("[Database] Migrations Drizzle synchronisées.");
} finally {
await pool.end();
}
}

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,13 @@
import { readFile } from "node:fs/promises";
import { resolve } from "node:path";
import { describe, expect, it } from "vitest";
describe("docker-compose de recette", () => {
it("utilise le nom unique du conteneur MySQL et évite l'alias générique partagé", async () => {
const compose = await readFile(resolve(process.cwd(), "docker-compose.yml"), "utf8");
expect(compose).toContain("DATABASE_URL=mysql://budget_user:BudgetSI2027!@itinova-budget-si-db:3306/budget_si");
expect(compose).not.toContain("DATABASE_URL=mysql://budget_user:BudgetSI2027!@db:3306/budget_si");
expect(compose).toContain("BUDGET_SI_DOMAIN:-budget-si.recette.santinova-soft.org");
});
});

View File

@@ -0,0 +1,14 @@
import { readFile } from "node:fs/promises";
import { resolve } from "node:path";
import { describe, expect, it } from "vitest";
describe("bootstrap de migrations Docker", () => {
it("exécute les migrations Drizzle avant de lancer le serveur", async () => {
const server = await readFile(resolve(process.cwd(), "server/_core/index.ts"), "utf8");
const migrationBootstrap = await readFile(resolve(process.cwd(), "server/databaseMigrations.ts"), "utf8");
expect(server).toContain("await runDatabaseMigrations()");
expect(migrationBootstrap).toContain("migrate(");
expect(migrationBootstrap).toContain("migrationsFolder: \"./drizzle\"");
});
});

View File

@@ -0,0 +1,14 @@
import { readFile } from "node:fs/promises";
import { resolve } from "node:path";
import { describe, expect, it } from "vitest";
describe("bootstrap du compte administrateur", () => {
it("crée le compte administrateur requis après migration sans stocker de mot de passe en clair", async () => {
const bootstrap = await readFile(resolve(process.cwd(), "server/databaseMigrations.ts"), "utf8");
expect(bootstrap).toContain('const INITIAL_ADMIN_LOGIN = "adminItinova"');
expect(bootstrap).toContain("await ensureInitialAdmin(pool)");
expect(bootstrap).toContain("await bcrypt.hash(INITIAL_ADMIN_PASSWORD");
expect(bootstrap).not.toContain("passwordHash: INITIAL_ADMIN_PASSWORD,");
});
});

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

@@ -0,0 +1,38 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
const originalStorageDir = process.env.PAYROLL_STORAGE_DIR;
const originalForgeUrl = process.env.BUILT_IN_FORGE_API_URL;
const originalForgeKey = process.env.BUILT_IN_FORGE_API_KEY;
afterEach(async () => {
vi.restoreAllMocks();
if (originalStorageDir === undefined) delete process.env.PAYROLL_STORAGE_DIR;
else process.env.PAYROLL_STORAGE_DIR = originalStorageDir;
if (originalForgeUrl === undefined) delete process.env.BUILT_IN_FORGE_API_URL;
else process.env.BUILT_IN_FORGE_API_URL = originalForgeUrl;
if (originalForgeKey === undefined) delete process.env.BUILT_IN_FORGE_API_KEY;
else process.env.BUILT_IN_FORGE_API_KEY = originalForgeKey;
vi.resetModules();
});
describe("stockage persistant des bulletins", () => {
it("conserve un PDF déjà chiffré dans le répertoire persistant quand Forge est indisponible", async () => {
const storageDir = await mkdtemp(join(tmpdir(), "itinova-payroll-storage-"));
process.env.PAYROLL_STORAGE_DIR = storageDir;
delete process.env.BUILT_IN_FORGE_API_URL;
delete process.env.BUILT_IN_FORGE_API_KEY;
vi.stubGlobal("fetch", vi.fn(() => { throw new Error("Forge ne doit pas être appelé"); }));
const { payrollStorageGetBuffer, payrollStoragePut } = await import("./storage");
const ciphertext = Buffer.from("octets-chiffres-de-test", "utf8");
const stored = await payrollStoragePut("salaires/test.pdf.enc", ciphertext, "application/octet-stream");
expect(stored.key).toMatch(/^salaires\/test\.pdf_[a-f0-9]{8}\.enc$/);
await expect(payrollStorageGetBuffer(stored.key)).resolves.toEqual(ciphertext);
await rm(storageDir, { recursive: true, force: true });
});
});

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({

View File

@@ -9,14 +9,14 @@ vi.mock("./db", () => ({
getSalaireLiasseById: vi.fn(),
replaceSalaireLiasseIndex: vi.fn(),
}));
vi.mock("./storage", () => ({ storagePut: vi.fn(), storageGetBuffer: vi.fn() }));
vi.mock("./storage", () => ({ payrollStoragePut: vi.fn(), payrollStorageGetBuffer: vi.fn() }));
vi.mock("pdf-parse/lib/pdf-parse.js", () => ({ default: vi.fn() }));
import { sdk } from "./_core/sdk";
import { createSalaireLiasse, getSalaireLiasseById, getSalaireLiasseByPeriod } from "./db";
import { encryptPayrollPdf } from "./payrollCrypto";
import { registerSalairesHttpRoutes } from "./salairesHttp";
import { storageGetBuffer, storagePut } from "./storage";
import { payrollStorageGetBuffer, payrollStoragePut } from "./storage";
import pdfParse from "pdf-parse/lib/pdf-parse.js";
const EXTRACTED_TEXT = `##BULLETIN##05-2026##009999##DUPONT##TEST##
@@ -49,13 +49,13 @@ describe("routes HTTP Salaires", () => {
.expect(403);
expect(createSalaireLiasse).not.toHaveBeenCalled();
expect(storagePut).not.toHaveBeenCalled();
expect(payrollStoragePut).not.toHaveBeenCalled();
});
it("chiffre et archive une liasse administrateur sans persister de donnée sensible", async () => {
(sdk.authenticateRequest as ReturnType<typeof vi.fn>).mockResolvedValue({ id: 1, role: "admin" });
(pdfParse as ReturnType<typeof vi.fn>).mockResolvedValue({ text: EXTRACTED_TEXT });
(storagePut as ReturnType<typeof vi.fn>).mockResolvedValue({ key: "salaires/2026/05/liasse_opaque.enc" });
(payrollStoragePut as ReturnType<typeof vi.fn>).mockResolvedValue({ key: "salaires/2026/05/liasse_opaque.enc" });
(createSalaireLiasse as ReturnType<typeof vi.fn>).mockResolvedValue({ liasseId: 12, bulletins: 1 });
const response = await request(makeApp())
@@ -81,7 +81,7 @@ describe("routes HTTP Salaires", () => {
const plaintext = Buffer.from("%PDF-1.7 contenu de bulletin", "utf8");
const encrypted = encryptPayrollPdf(plaintext);
(getSalaireLiasseById as ReturnType<typeof vi.fn>).mockResolvedValue({ id: 30001, stockageKey: "opaque", ivBase64: encrypted.ivBase64, authTagBase64: encrypted.authTagBase64, annee: 2026, mois: 5 });
(storageGetBuffer as ReturnType<typeof vi.fn>).mockResolvedValue(encrypted.ciphertext);
(payrollStorageGetBuffer as ReturnType<typeof vi.fn>).mockResolvedValue(encrypted.ciphertext);
const response = await request(makeApp()).get("/api/salaires/liasses/30001/pdf").expect(200);
@@ -103,7 +103,7 @@ describe("routes HTTP Salaires", () => {
(sdk.authenticateRequest as ReturnType<typeof vi.fn>).mockResolvedValue({ id: 1, role: "admin" });
const encrypted = encryptPayrollPdf(Buffer.from("%PDF-1.7 contenu de bulletin", "utf8"));
(getSalaireLiasseById as ReturnType<typeof vi.fn>).mockResolvedValue({ id: 30001, stockageKey: "opaque", ivBase64: encrypted.ivBase64, authTagBase64: encrypted.authTagBase64, annee: 2026, mois: 5 });
(storageGetBuffer as ReturnType<typeof vi.fn>).mockResolvedValue(encrypted.ciphertext);
(payrollStorageGetBuffer as ReturnType<typeof vi.fn>).mockResolvedValue(encrypted.ciphertext);
(pdfParse as ReturnType<typeof vi.fn>).mockResolvedValue({ text: EXTRACTED_TEXT });
(replaceSalaireLiasseIndex as ReturnType<typeof vi.fn>).mockResolvedValue({ bulletins: 1 });
@@ -113,6 +113,6 @@ describe("routes HTTP Salaires", () => {
statutExtraction: "ready",
bulletins: [expect.objectContaining({ matricule: "009999" })],
}));
expect(storagePut).not.toHaveBeenCalled();
expect(payrollStoragePut).not.toHaveBeenCalled();
});
});

View File

@@ -6,7 +6,7 @@ import { createSalaireLiasse, getSalaireLiasseById, getSalaireLiasseByPeriod, re
import { decryptPayrollPdf, encryptPayrollPdf } from "./payrollCrypto";
import { extractPayrollBulletins, type PayrollExtractionResult } from "./payrollParser";
import { extractPayrollPdfText } from "./payrollPdfText";
import { storageGetBuffer, storagePut } from "./storage";
import { payrollStorageGetBuffer, payrollStoragePut } from "./storage";
const MAX_PDF_BYTES = 25 * 1024 * 1024;
const upload = multer({
@@ -82,7 +82,7 @@ export function registerSalairesHttpRoutes(app: Express) {
try {
const encrypted = encryptPayrollPdf(file.buffer);
const archive = await storagePut(
const archive = await payrollStoragePut(
`salaires/${annee}/${String(mois).padStart(2, "0")}/liasse.pdf.enc`,
encrypted.ciphertext,
"application/octet-stream",
@@ -128,7 +128,7 @@ export function registerSalairesHttpRoutes(app: Express) {
return;
}
try {
const ciphertext = await storageGetBuffer(liasse.stockageKey);
const ciphertext = await payrollStorageGetBuffer(liasse.stockageKey);
const pdf = decryptPayrollPdf({ ciphertext, ivBase64: liasse.ivBase64, authTagBase64: liasse.authTagBase64 });
if (!isPdf(pdf)) throw new Error("Invalid decrypted PDF signature");
const extraction = extractPayrollBulletins(await extractPayrollPdfText(pdf));
@@ -160,7 +160,7 @@ export function registerSalairesHttpRoutes(app: Express) {
}
try {
const ciphertext = await storageGetBuffer(liasse.stockageKey);
const ciphertext = await payrollStorageGetBuffer(liasse.stockageKey);
const pdf = decryptPayrollPdf({
ciphertext,
ivBase64: liasse.ivBase64,

View File

@@ -2,6 +2,8 @@
// Uploads via Forge Server presigned URL to S3 (PUT direct).
// Downloads return /manus-storage/{key} paths served via 307 redirect.
import { mkdir, readFile, writeFile } from "node:fs/promises";
import { dirname, relative, resolve } from "node:path";
import { ENV } from "./_core/env";
function getForgeConfig() {
@@ -107,3 +109,43 @@ export async function storageGetBuffer(relKey: string): Promise<Buffer> {
if (!response.ok) throw new Error(`Storage download failed (${response.status})`);
return Buffer.from(await response.arrayBuffer());
}
/**
* Répertoire persistant optionnel réservé aux PDF de paie déjà chiffrés.
* Il n'est utilisé que sur les environnements sans configuration Forge, où le
* volume Docker hôte survit aux reconstructions de l'image applicative.
*/
function getPayrollStoragePath(relKey: string): string | null {
const configuredDirectory = process.env.PAYROLL_STORAGE_DIR?.trim();
if (!configuredDirectory) return null;
const root = resolve(configuredDirectory);
const destination = resolve(root, normalizeKey(relKey));
const pathFromRoot = relative(root, destination);
if (pathFromRoot.startsWith("..") || pathFromRoot === "") {
throw new Error("Invalid payroll storage key");
}
return destination;
}
export async function payrollStoragePut(
relKey: string,
data: Buffer | Uint8Array | string,
contentType = "application/octet-stream",
): Promise<{ key: string; url: string }> {
const localKey = appendHashSuffix(normalizeKey(relKey));
const localDestination = getPayrollStoragePath(localKey);
if (!localDestination) return storagePut(relKey, data, contentType);
await mkdir(dirname(localDestination), { recursive: true, mode: 0o700 });
await writeFile(localDestination, data, { mode: 0o600, flag: "wx" });
// Les appels paie n'exposent jamais cette URL : la lecture passe exclusivement
// par la route administrateur qui déchiffre côté serveur.
return { key: localKey, url: `/manus-storage/${localKey}` };
}
export async function payrollStorageGetBuffer(relKey: string): Promise<Buffer> {
const localDestination = getPayrollStoragePath(relKey);
if (!localDestination) return storageGetBuffer(relKey);
return readFile(localDestination);
}

52
todo.md
View File

@@ -151,8 +151,8 @@
## Mise à jour des astreintes selon le planning
- [x] Identifier les semaines d'astreinte 2025 et 2026 du planning pour les six salariés ciblés
- [x] Tracer explicitement les segments partiels à cheval entre exercices, exclus du décompte de semaine complète
- [ ] Intégrer toutes les semaines à cheval explicitement confirmées par les bulletins et vérifier les montants annuels
- [ ] Intégrer la semaine du 29/12/2025 de Jérôme Pascuzzi confirmée par le bulletin de janvier 2026
- [x] Intégrer toutes les semaines à cheval explicitement confirmées par les bulletins et vérifier les montants annuels — écarté par la règle finale lundi-lundi complète, qui exclut les segments à cheval entre exercices
- [x] Intégrer la semaine du 29/12/2025 de Jérôme Pascuzzi confirmée par le bulletin de janvier 2026 — écartée par la même règle lundi-lundi complète appliquée au classeur livré
- [x] Relier le tableau principal aux totaux annuels d'astreinte des onglets dédiés
- [x] Vérifier les décomptes chiffrés et le fichier Excel final actualisé
@@ -179,18 +179,58 @@
## Sécurité des bulletins de salaire
- [x] Chiffrer les fichiers PDF avant leur stockage dédié et ne conserver en base que les métadonnées et références
- [x] Contrôler que la consultation des PDFs chiffrés est réservée aux administrateurs
- [ ] Vérifier que les fichiers stockés restent disponibles après rebuild, mise à jour et réinitialisation des conteneurs
- [x] Vérifier que les fichiers stockés restent disponibles après rebuild, mise à jour et réinitialisation des conteneurs
- [x] Générer et valider une clé AES-256 dédiée aux bulletins de salaire
## Déploiement de recette — Fenêtre Salaires
- [x] Vérifier les contrôles complets avant la publication
- [ ] Pousser la fenêtre Salaires vers Gitea et déclencher la CI/CD de recette
- [ ] Vérifier l'application Salaires déployée en HTTPS sur la recette
- [x] Pousser la fenêtre Salaires vers Gitea et déclencher la CI/CD de recette
- [x] Vérifier l'application Salaires déployée en HTTPS sur la recette
- [x] Configurer la clé AES des bulletins dans l'environnement sécurisé de recette et recréer le conteneur
- [x] Transmettre explicitement PAYROLL_ENCRYPTION_KEY au conteneur applicatif de recette
- [x] Vérifier la disponibilité du stockage persistant chiffré dans l'environnement de recette
- [x] Stocker les PDF déjà chiffrés sur le volume persistant de recette si Forge n'est pas configuré
- [x] Valider en recette l'import, l'index et la consultation d'une liasse réelle sans exposer de données de paie
- [x] Appliquer et enregistrer les migrations Salaires dans la base de recette avant l'import mensuel
## Exécution en arrière-plan
- [ ] Finaliser la fenêtre Salaires et le déploiement de recette sans intervention utilisateur
- [x] Finaliser la fenêtre Salaires et le déploiement de recette sans intervention utilisateur
## Validation bout en bout — Salaires
- [x] Importer une liasse PDF de contrôle avec un compte administrateur, contrôler son index, le filtre et la consultation sécurisée
- [x] Redémarrer le serveur puis relire une liasse archivée pour confirmer la persistance hors conteneur
- [x] Permettre à un administrateur de réindexer sans écraser une liasse déjà archivée
- [x] Vérifier visuellement la page Salaires de recette dans une session administrateur : navigation, filtres, liste et statuts
- [x] Ouvrir une liasse depuis le bouton de l'interface de recette afin de valider le parcours utilisateur de consultation
- [x] Corriger l'alerte frontale indiquant à tort un chiffrement indisponible en recette
- [x] Corriger l'accès à la base de recette empêchant la connexion administrateur après le dernier redéploiement
## Promotion Salaires — recette et production
- [x] Vérifier la version Salaires active et le statut Gitea de recette avant promotion
- [x] Mettre à jour le dashboard et le portail de recette pour la fenêtre Salaires
- [x] Promouvoir la version Salaires validée vers la production depuis le dépôt Gitea via le flux Santinova de déploiement sécurisé
- [x] Mettre à jour le dashboard et le portail de production pour la fenêtre Salaires
- [x] Vérifier les accès HTTPS administrateur et le statut des déploiements dans les deux environnements
- [x] Paramétrer le domaine Traefik Budget SI par environnement pour que la production réponde sur son URL dédiée
- [x] Corriger le visuel Budget SI non chargé sur la carte du portail de recette
- [x] Créer et synchroniser le dépôt Gitea production Budget SI avant le déploiement du serveur de production
- [x] Initialiser et vérifier le schéma de la base Budget SI de production avant la validation administrateur
- [x] Exécuter les migrations Drizzle au démarrage du conteneur pour les futurs déploiements de production
- [x] Créer le compte administrateur initial requis lorsque la base Budget SI est vide
- [x] Synchroniser le correctif d'initialisation de la base vers le dépôt et le serveur de production
- [x] Corriger la connexion administrateur de recette restée en attente après le redéploiement du bootstrap
- [x] Diagnostiquer puis redémarrer les services d'infrastructure de recette autorisés pour rétablir l'API et le dashboard
## Masse salariale — prévisionnel et réel annuel
- [x] Analyser la structure et les formules du fichier modèle Masse salariale fourni
- [x] Importer les données du fichier comme réel et prévisionnel 2025, ainsi que prévisionnel 2026 et 2027
- [x] Calculer le réel 2026 exclusivement à partir des liasses mensuelles importées dans Salaires
- [x] Ajouter les colonnes annuelles prévisionnel et réel, avec les calculs automatiques requis
- [x] Autoriser uniquement l'édition administrateur des primes et évolutions pour l'année en cours ou future
- [x] Tester les règles de calcul, de données réelles et d'autorisation de modification
- [ ] Déployer l'évolution validée en recette puis en production
- [x] Afficher les centimes des montants de masse salariale lorsque le fichier modèle en contient
- [x] Reprendre le brut annuel avec primes calculé par le fichier modèle comme référence prévisionnelle exacte
- [ ] Synchroniser le fichier prévisionnel et les liasses mensuelles nécessaires au réel 2026 en recette et production
- [x] Archiver les liasses de janvier à avril 2026 fournies dans Salaires avant le calcul réel annuel
- [x] Ajouter un téléversement administrateur du fichier Excel de référence dans Masse salariale