diff --git a/client/src/pages/MasseSalariale.tsx b/client/src/pages/MasseSalariale.tsx index deada39..ced3243 100644 --- a/client/src/pages/MasseSalariale.tsx +++ b/client/src/pages/MasseSalariale.tsx @@ -1,261 +1,179 @@ -// MasseSalariale.tsx — Données de rémunération SANTINOVA (accès administrateur) +// MasseSalariale.tsx — Prévisionnel et réel SANTINOVA, accès administrateur. -import { Fragment, useMemo, useState } from 'react'; -import { 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, LockKeyhole, ShieldCheck, 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(null); - const [evolutionDraft, setEvolutionDraft] = useState(''); + const [primeDraft, setPrimeDraft] = useState(""); + const [evolutionDraft, setEvolutionDraft] = useState(""); 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), + }); }; return (
setSidebarCollapsed((value) => !value)} /> -
-
- -
+
-

- Masse salariale — SANTINOVA -

-

- Rémunérations 2025–2027 et évolutions salariales manuelles -

+

Masse salariale — SANTINOVA

+

Prévisionnel et réel annuel, issus du fichier de référence et des liasses archivées.

-
- - Accès administrateur -
+
Accès administrateur
-
+
{ANNEES.map((annee) => { - const total = totalsByYear.get(annee); - const isProvisional = total?.status === 'cumul_provisoire'; - return ( -
-
-
- - {annee} -
- - {total?.status === 'annuel' ? 'Année clôturée' : isProvisional ? 'Cumul au dernier bulletin' : 'À renseigner'} - -
-

- {formatEuros(total?.annualGrossWithBonusesCents)} -

-

- Brut avec primes — {total?.salaryCount ?? 0} salarié{(total?.salaryCount ?? 0) > 1 ? 's' : ''} -

-
- ); + const total = totals.get(annee); + const hasActual = (total?.actualCount ?? 0) > 0; + return
+
{annee}
+
+

Prévisionnel

{formatEuros(total?.forecast)}

{total?.forecastCount ?? 0} salarié(s)

+

Réel

{hasActual ? formatEuros(total?.actual) : "—"}

{hasActual ? `${total?.actualCount} salarié(s) · ${formatMonths(total?.actualMonths ? Array.from({ length: total.actualMonths }, (_, index) => index) : [])}` : "Aucune liasse"}

+
+
; })}
-
- -

- 2025 est issu des cumuls annuels de décembre. 2026 est un cumul provisoire au dernier bulletin disponible pour chaque salarié (janvier à août). Aucun salaire 2027 n’est calculé automatiquement ; seules les évolutions peuvent être saisies. -

-
+

Réel 2026 : cumul strict des liasses mensuelles disponibles, sans annualisation. Le brut hors astreinte retire uniquement les primes d’astreinte. Les cellules vertes Prime et Évolution sont modifiables seulement pour {data?.currentYear ?? "l’exercice courant"} et les années suivantes.

-
-
- -
-
-

Détail par salarié

-

Cliquez sur une évolution pour la saisir ou la modifier.

-
-
- +

Détail par salarié

Prévisionnel issu du fichier modèle ; réel issu des clôtures et liasses PDF archivées.

-
- +
+
- - - - - {ANNEES.map((annee) => )} - - - {ANNEES.flatMap((annee) => [ - , - , - , - , - , - ])} - + {ANNEES.map((annee) => )} + {ANNEES.slice(1).flatMap((annee) => [, ])} + {ANNEES.flatMap((annee) => [ + , + , + , + , + , + , + , + , + , + , + , + , + ])} - - {data?.salaries.map((salarie) => ( - - - - - {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 ( - - - - - - - - ); - })} - - ))} - + {data?.salaries.map((salarie) => {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 + + + + + ; + })})}
SalariéPosteEmbauche{annee}
Annuel hors primesAnnuel avec primesMensuel hors primesTaux chargesÉvolution
SalariéPosteEmbauche{annee}
PrévisionnelRéelPrévisionnelRéel
Annuel hors astreinteAnnuel avec primesMensuel hors astreinteAstreintesTaux chargesÉvolutionPrimeCumul hors astreinteCumul avec primesMoy. mensuelleAstreintesMois
-

{salarie.nom} {salarie.prenom}

-

{salarie.matricule}

-
{salarie.poste}{formatDate(salarie.dateEmbauche)}{formatEuros(remuneration?.salaireAnnuelBrutHorsPrimesCents)}{formatEuros(remuneration?.salaireAnnuelBrutAvecPrimesCents)}{formatEuros(remuneration?.salaireMensuelBrutHorsPrimesCents)}{formatPercent(remuneration?.tauxChargeBps)} - {editingKey === key ? ( -
- 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} - /> - -
- ) : ( - - )} -

{salarie.nom} {salarie.prenom}

{salarie.matricule}

{salarie.poste}{formatDate(salarie.dateEmbauche)}{formatEuros(forecast?.brutHorsAstreinteCents)}{formatEuros(forecast?.brutAvecPrimesCents)}{formatEuros(forecast?.brutMensuelHorsAstreinteCents)}{formatEuros(forecast?.primeAstreinteCents)}{formatPercent(forecast?.tauxChargeBps)}{editing ? 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 ? : {formatEuros(forecast?.evolutionCents)}}{editing ?
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} />
: forecast?.editable ? : {formatEuros(forecast?.primeCents)}}
{formatEuros(actual?.brutHorsAstreinteCents)}{formatEuros(actual?.brutAvecPrimesCents)}{formatEuros(actual?.brutMensuelHorsAstreinteCents)}{formatEuros(actual?.primeAstreinteCents)}{formatMonths(actual?.moisDisponibles)}
diff --git a/docs/masse-salariale-previsionnel-reel.md b/docs/masse-salariale-previsionnel-reel.md new file mode 100644 index 0000000..9f0f331 --- /dev/null +++ b/docs/masse-salariale-previsionnel-reel.md @@ -0,0 +1,35 @@ +# Masse salariale SANTINOVA — Prévisionnel et réel + +## Source et périmètre + +Le fichier `TableaurecapMassesalarialeSANTINOVA-28.08.26.xlsx`, fourni le 31 août 2026, sert de référence pour les prévisionnels 2025, 2026 et 2027. Il comporte une feuille **Masse salariale** et une feuille **Méthode**. Le détail reste exclusivement accessible aux administrateurs. + +## Règles de restitution + +| Année | Prévisionnel | Réel | +|---|---|---| +| 2025 | Valeurs du fichier | Mêmes valeurs que le prévisionnel, année clôturée | +| 2026 | Valeurs du fichier | Cumul des seules liasses mensuelles archivées dans Salaires, sans extrapolation | +| 2027 | Valeurs du fichier | Non disponible tant qu'aucune liasse mensuelle n'est importée | + +Le brut hors astreinte correspond au brut avec primes diminué **uniquement** des primes d'astreinte. Les autres éléments inclus au brut, notamment les primes d'activité, restent dans le montant. + +## Édition et calcul + +Les valeurs de référence importées sont conservées. Pour l'année courante et les années futures, un administrateur peut modifier seulement les colonnes **Prime** et **Évolution** du prévisionnel. Les totaux prévisionnels sont recalculés à partir de la référence, en ajoutant ou retirant l'écart entre la valeur modifiée et la valeur initiale. Les exercices antérieurs sont verrouillés. + +Le réel 2026 affiche le nombre de mois effectivement disponibles et ne transforme jamais un cumul partiel en prévision annuelle. + +La vérification visuelle de développement a confirmé les trois années, les blocs prévisionnel-réel, le cumul réel 2026 limité aux deux liasses disponibles, et la présence des colonnes éditables uniquement sur les exercices autorisés. Les montants doivent néanmoins conserver leurs centimes à l'affichage afin d'éviter tout arrondi de restitution. + +Après correction, l'interface restitue les centimes issus du fichier modèle. La table détaille, pour chacune des années 2025, 2026 et 2027, les colonnes prévisionnelles et réelles séparées. Les valeurs réelles non disponibles restent indiquées comme absentes plutôt que remplacées par une estimation. + +La liasse de janvier 2026 a été archivée depuis la fenêtre Salaires, chiffrée avant stockage et indexée complètement. Elle alimente désormais le cumul réel 2026 sans réécriture d'une valeur prévisionnelle. + +La liasse de février 2026 a également été archivée et indexée. Le comparatif M-1 de Salaires est disponible à partir de février ; les variations restent reliées aux rubriques extraites et non à une estimation annuelle. + +La liasse de mars 2026 est archivée avec un statut partiel : les fiches non reconnues restent signalées et le PDF conservé, tandis que le cumul réel utilise uniquement les bulletins effectivement indexés. + +La liasse d'avril 2026 est également archivée avec un statut partiel. Le réel annuel conserve donc explicitement sa couverture mensuelle et ne masque pas les extractions incomplètes. + +Les contrôles automatisés finaux ont réussi : 72 tests couvrant notamment l'accès administrateur, les projections, l'import du modèle, la confidentialité des bulletins et les règles de persistance. Le contrôle TypeScript et le build de production ont également réussi. diff --git a/drizzle/0011_dashing_grey_gargoyle.sql b/drizzle/0011_dashing_grey_gargoyle.sql new file mode 100644 index 0000000..ff8a075 --- /dev/null +++ b/drizzle/0011_dashing_grey_gargoyle.sql @@ -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`); \ No newline at end of file diff --git a/drizzle/meta/0011_snapshot.json b/drizzle/meta/0011_snapshot.json new file mode 100644 index 0000000..baf9a49 --- /dev/null +++ b/drizzle/meta/0011_snapshot.json @@ -0,0 +1,1521 @@ +{ + "version": "5", + "dialect": "mysql", + "id": "20698cdf-288c-40b2-a9ec-cc4b87380e59", + "prevId": "bad6983d-6c4f-49a9-806b-f42472f06063", + "tables": { + "capex_lignes": { + "name": "capex_lignes", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "annee": { + "name": "annee", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "etablissementCode": { + "name": "etablissementCode", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "cle": { + "name": "cle", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "montant": { + "name": "montant", + "type": "decimal(12,2)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": { + "capex_lignes_annee_etab_cle_idx": { + "name": "capex_lignes_annee_etab_cle_idx", + "columns": [ + "annee", + "etablissementCode", + "cle" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "capex_lignes_id": { + "name": "capex_lignes_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "etablissements": { + "name": "etablissements", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "code": { + "name": "code", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "nom": { + "name": "nom", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "groupe": { + "name": "groupe", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "ville": { + "name": "ville", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "actif": { + "name": "actif", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "etablissements_id": { + "name": "etablissements_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": { + "etablissements_code_unique": { + "name": "etablissements_code_unique", + "columns": [ + "code" + ] + } + }, + "checkConstraint": {} + }, + "inventaire_meta": { + "name": "inventaire_meta", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "annee": { + "name": "annee", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "filename": { + "name": "filename", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "dateImport": { + "name": "dateImport", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "nbEtablissements": { + "name": "nbEtablissements", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "nbFixes": { + "name": "nbFixes", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "nbPortables": { + "name": "nbPortables", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "inventaire_meta_id": { + "name": "inventaire_meta_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": { + "inventaire_meta_annee_unique": { + "name": "inventaire_meta_annee_unique", + "columns": [ + "annee" + ] + } + }, + "checkConstraint": {} + }, + "inventaire_postes": { + "name": "inventaire_postes", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "annee": { + "name": "annee", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "etablissementCode": { + "name": "etablissementCode", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "libelle": { + "name": "libelle", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "typePoste": { + "name": "typePoste", + "type": "enum('fixe','portable')", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "dateRef": { + "name": "dateRef", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "ageAns": { + "name": "ageAns", + "type": "decimal(5,2)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "modele": { + "name": "modele", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "fabricant": { + "name": "fabricant", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "inventaire_postes_id": { + "name": "inventaire_postes_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "masse_salariale_evolutions": { + "name": "masse_salariale_evolutions", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "salarieId": { + "name": "salarieId", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "annee": { + "name": "annee", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "evolutionSalarialeBps": { + "name": "evolutionSalarialeBps", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "createdBy": { + "name": "createdBy", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": { + "masse_salariale_evolution_salarie_annee_idx": { + "name": "masse_salariale_evolution_salarie_annee_idx", + "columns": [ + "salarieId", + "annee" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "masse_salariale_evolutions_id": { + "name": "masse_salariale_evolutions_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "masse_salariale_remunerations": { + "name": "masse_salariale_remunerations", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "salarieId": { + "name": "salarieId", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "annee": { + "name": "annee", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "nature": { + "name": "nature", + "type": "enum('previsionnel')", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'previsionnel'" + }, + "salaireAnnuelBrutHorsPrimesCents": { + "name": "salaireAnnuelBrutHorsPrimesCents", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "salaireAnnuelBrutAvecPrimesCents": { + "name": "salaireAnnuelBrutAvecPrimesCents", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "salaireMensuelBrutHorsPrimesCents": { + "name": "salaireMensuelBrutHorsPrimesCents", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tauxChargeBps": { + "name": "tauxChargeBps", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "primeAstreinteCents": { + "name": "primeAstreinteCents", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "primeCents": { + "name": "primeCents", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "evolutionCents": { + "name": "evolutionCents", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "primeReferenceCents": { + "name": "primeReferenceCents", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "evolutionReferenceCents": { + "name": "evolutionReferenceCents", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "periodeReference": { + "name": "periodeReference", + "type": "varchar(7)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "statut": { + "name": "statut", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'annuel'" + }, + "source": { + "name": "source", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'bulletin'" + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": { + "masse_salariale_salarie_annee_nature_idx": { + "name": "masse_salariale_salarie_annee_nature_idx", + "columns": [ + "salarieId", + "annee", + "nature" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "masse_salariale_remunerations_id": { + "name": "masse_salariale_remunerations_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "masse_salariale_salaries": { + "name": "masse_salariale_salaries", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "matricule": { + "name": "matricule", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "nom": { + "name": "nom", + "type": "varchar(150)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "prenom": { + "name": "prenom", + "type": "varchar(150)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "poste": { + "name": "poste", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "dateEmbauche": { + "name": "dateEmbauche", + "type": "varchar(10)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "actif": { + "name": "actif", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "masse_salariale_salaries_id": { + "name": "masse_salariale_salaries_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": { + "masse_salariale_salaries_matricule_unique": { + "name": "masse_salariale_salaries_matricule_unique", + "columns": [ + "matricule" + ] + } + }, + "checkConstraint": {} + }, + "opex_bases_repartition": { + "name": "opex_bases_repartition", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "annee": { + "name": "annee", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "etablissementCode": { + "name": "etablissementCode", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "etablissementNom": { + "name": "etablissementNom", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "baseRepartition": { + "name": "baseRepartition", + "type": "decimal(20,6)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "baseRepartitionHep": { + "name": "baseRepartitionHep", + "type": "decimal(20,6)", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'0'" + }, + "modeManuel": { + "name": "modeManuel", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + } + }, + "indexes": { + "opex_bases_repartition_annee_etab_idx": { + "name": "opex_bases_repartition_annee_etab_idx", + "columns": [ + "annee", + "etablissementCode" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "opex_bases_repartition_id": { + "name": "opex_bases_repartition_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "opex_montants_etab": { + "name": "opex_montants_etab", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "annee": { + "name": "annee", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "etablissementCode": { + "name": "etablissementCode", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "libellePoste": { + "name": "libellePoste", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "montant": { + "name": "montant", + "type": "decimal(12,2)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": { + "opex_montants_annee_etab_poste_idx": { + "name": "opex_montants_annee_etab_poste_idx", + "columns": [ + "annee", + "etablissementCode", + "libellePoste" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "opex_montants_etab_id": { + "name": "opex_montants_etab_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "opex_postes": { + "name": "opex_postes", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "annee": { + "name": "annee", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "colIdx": { + "name": "colIdx", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "libelle": { + "name": "libelle", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "libelleCourt": { + "name": "libelleCourt", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "libelleDetail": { + "name": "libelleDetail", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "fournisseur": { + "name": "fournisseur", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "categorie": { + "name": "categorie", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "facturation": { + "name": "facturation", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "modeVentilation": { + "name": "modeVentilation", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'Prorata C 6'" + }, + "compte": { + "name": "compte", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "detail": { + "name": "detail", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "budgetN1": { + "name": "budgetN1", + "type": "decimal(12,2)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "montant": { + "name": "montant", + "type": "decimal(12,2)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "isCustom": { + "name": "isCustom", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": { + "opex_postes_annee_libelle_idx": { + "name": "opex_postes_annee_libelle_idx", + "columns": [ + "annee", + "libelle" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "opex_postes_id": { + "name": "opex_postes_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "opex_validated": { + "name": "opex_validated", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "annee": { + "name": "annee", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "validatedAt": { + "name": "validatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "validatedBy": { + "name": "validatedBy", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "opex_validated_id": { + "name": "opex_validated_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": { + "opex_validated_annee_unique": { + "name": "opex_validated_annee_unique", + "columns": [ + "annee" + ] + } + }, + "checkConstraint": {} + }, + "parametres_app": { + "name": "parametres_app", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "cle": { + "name": "cle", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "valeur": { + "name": "valeur", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "parametres_app_id": { + "name": "parametres_app_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": { + "parametres_app_cle_unique": { + "name": "parametres_app_cle_unique", + "columns": [ + "cle" + ] + } + }, + "checkConstraint": {} + }, + "salaires_bulletins": { + "name": "salaires_bulletins", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "liasseId": { + "name": "liasseId", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "matricule": { + "name": "matricule", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "nom": { + "name": "nom", + "type": "varchar(150)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "prenom": { + "name": "prenom", + "type": "varchar(150)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "poste": { + "name": "poste", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "brutMensuelCents": { + "name": "brutMensuelCents", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "brutAvecPrimesCents": { + "name": "brutAvecPrimesCents", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "primeAstreinteCents": { + "name": "primeAstreinteCents", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "explicationEcart": { + "name": "explicationEcart", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "numeroPage": { + "name": "numeroPage", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": { + "salaires_bulletins_liasse_matricule_idx": { + "name": "salaires_bulletins_liasse_matricule_idx", + "columns": [ + "liasseId", + "matricule" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "salaires_bulletins_id": { + "name": "salaires_bulletins_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "salaires_liasses": { + "name": "salaires_liasses", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "annee": { + "name": "annee", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "mois": { + "name": "mois", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "nomFichier": { + "name": "nomFichier", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "stockageKey": { + "name": "stockageKey", + "type": "varchar(512)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "empreinteSha256": { + "name": "empreinteSha256", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tailleOctets": { + "name": "tailleOctets", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ivBase64": { + "name": "ivBase64", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "authTagBase64": { + "name": "authTagBase64", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "versionCle": { + "name": "versionCle", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "statutExtraction": { + "name": "statutExtraction", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'ready'" + }, + "erreurExtraction": { + "name": "erreurExtraction", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "importePar": { + "name": "importePar", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "importeLe": { + "name": "importeLe", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + } + }, + "indexes": { + "salaires_liasses_periode_idx": { + "name": "salaires_liasses_periode_idx", + "columns": [ + "annee", + "mois" + ], + "isUnique": true + }, + "salaires_liasses_periode_empreinte_idx": { + "name": "salaires_liasses_periode_empreinte_idx", + "columns": [ + "annee", + "mois", + "empreinteSha256" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "salaires_liasses_id": { + "name": "salaires_liasses_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": { + "salaires_liasses_stockageKey_unique": { + "name": "salaires_liasses_stockageKey_unique", + "columns": [ + "stockageKey" + ] + }, + "salaires_liasses_empreinteSha256_unique": { + "name": "salaires_liasses_empreinteSha256_unique", + "columns": [ + "empreinteSha256" + ] + } + }, + "checkConstraint": {} + }, + "user_etablissements": { + "name": "user_etablissements", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "userId": { + "name": "userId", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "etablissementCode": { + "name": "etablissementCode", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "user_etablissements_id": { + "name": "user_etablissements_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "users": { + "name": "users", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "login": { + "name": "login", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "varchar(320)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "passwordHash": { + "name": "passwordHash", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "firstName": { + "name": "firstName", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "lastName": { + "name": "lastName", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "enum('admin','standard','readonly')", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'standard'" + }, + "isActive": { + "name": "isActive", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + }, + "lastSignedIn": { + "name": "lastSignedIn", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "users_id": { + "name": "users_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": { + "users_login_unique": { + "name": "users_login_unique", + "columns": [ + "login" + ] + } + }, + "checkConstraint": {} + } + }, + "views": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "tables": {}, + "indexes": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index eaa9c2b..ccf25ae 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -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 } ] } \ No newline at end of file diff --git a/drizzle/schema.ts b/drizzle/schema.ts index a768b73..ae3e3cd 100644 --- a/drizzle/schema.ts +++ b/drizzle/schema.ts @@ -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; diff --git a/scripts/analyzeMasseSalarialeWorkbook.py b/scripts/analyzeMasseSalarialeWorkbook.py new file mode 100644 index 0000000..dfb26f9 --- /dev/null +++ b/scripts/analyzeMasseSalarialeWorkbook.py @@ -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}") diff --git a/scripts/diagnoseMasseSalarialeWorkbook.ts b/scripts/diagnoseMasseSalarialeWorkbook.ts new file mode 100644 index 0000000..85b8cfc --- /dev/null +++ b/scripts/diagnoseMasseSalarialeWorkbook.ts @@ -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>(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>((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), +})); diff --git a/scripts/importMasseSalarialeWorkbook.ts b/scripts/importMasseSalarialeWorkbook.ts new file mode 100644 index 0000000..d3bd373 --- /dev/null +++ b/scripts/importMasseSalarialeWorkbook.ts @@ -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>(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>((counts, row) => { + counts[row.annee] = (counts[row.annee] ?? 0) + 1; + return counts; +}, {}); +console.log(JSON.stringify({ salaries: result.salaries, remunerationsByYear: byYear })); diff --git a/server/auth.local.test.ts b/server/auth.local.test.ts index af0fdd7..611a531 100644 --- a/server/auth.local.test.ts +++ b/server/auth.local.test.ts @@ -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).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).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).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é ───────────────── diff --git a/server/db.ts b/server/db.ts index bcc8df6..86e76da 100644 --- a/server/db.ts +++ b/server/db.ts @@ -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, diff --git a/server/masseSalarialeProjection.test.ts b/server/masseSalarialeProjection.test.ts new file mode 100644 index 0000000..0342fbc --- /dev/null +++ b/server/masseSalarialeProjection.test.ts @@ -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); + }); +}); diff --git a/server/masseSalarialeProjection.ts b/server/masseSalarialeProjection.ts new file mode 100644 index 0000000..7638735 --- /dev/null +++ b/server/masseSalarialeProjection.ts @@ -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 & { + 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 { + const forecasts = new Map(); + 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> { + const grouped = new Map>(); + for (const input of inputs) { + const byYear = grouped.get(input.matricule) ?? new Map(); + byYear.set(input.annee, [...(byYear.get(input.annee) ?? []), input]); + grouped.set(input.matricule, byYear); + } + + const actuals = new Map>(); + for (const [matricule, byYear] of Array.from(grouped.entries())) { + const projections = new Map(); + 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(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; +} diff --git a/server/masseSalarialeWorkbookImport.test.ts b/server/masseSalarialeWorkbookImport.test.ts new file mode 100644 index 0000000..51d4105 --- /dev/null +++ b/server/masseSalarialeWorkbookImport.test.ts @@ -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.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.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); + }); +}); diff --git a/server/masseSalarialeWorkbookImport.ts b/server/masseSalarialeWorkbookImport.ts new file mode 100644 index 0000000..8098770 --- /dev/null +++ b/server/masseSalarialeWorkbookImport.ts @@ -0,0 +1,97 @@ +import type { MasseSalarialeImportRemuneration, MasseSalarialeImportSalarie } from "./db"; + +type SpreadsheetValue = string | number | Date | null | undefined; + +function cents(value: SpreadsheetValue): number { + if (typeof value === "number") return Math.round(value * 100); + if (typeof value !== "string") return 0; + const normalized = value.replace(/[\s\u00a0]/g, "").replace(",", "."); + const parsed = Number(normalized); + return Number.isFinite(parsed) ? Math.round(parsed * 100) : 0; +} + +function bps(value: SpreadsheetValue): number | null { + if (value === null || value === undefined || value === "") return null; + const numeric = typeof value === "number" ? value : Number(String(value).replace(",", ".")); + return Number.isFinite(numeric) ? Math.round(numeric * 10_000) : null; +} + +function isoDate(value: SpreadsheetValue): string | null { + if (value instanceof Date && !Number.isNaN(value.valueOf())) return value.toISOString().slice(0, 10); + if (typeof value === "number" && Number.isFinite(value)) { + const excelDate = new Date(Date.UTC(1899, 11, 30) + Math.round(value * 86_400_000)); + return Number.isNaN(excelDate.valueOf()) ? null : excelDate.toISOString().slice(0, 10); + } + if (typeof value === "string" && /^\d{4}-\d{2}-\d{2}$/.test(value)) return value; + if (typeof value === "string" && /^\d{2}\/\d{2}\/\d{4}$/.test(value)) { + const [day, month, year] = value.split("/"); + return `${year}-${month}-${day}`; + } + return null; +} + +function text(value: SpreadsheetValue): string { + return typeof value === "string" || typeof value === "number" ? String(value).trim() : ""; +} + +export type MasseSalarialeWorkbookImport = { + salaries: MasseSalarialeImportSalarie[]; + remunerations: MasseSalarialeImportRemuneration[]; +}; + +/** + * Convertit les lignes de la feuille modèle (index Excel 1, en-tête sur la + * ligne 5) sans évaluer de formules côté application : les valeurs calculées + * et sauvegardées par le fichier fourni servent de référence importée. + */ +export function parseMasseSalarialeWorkbookRows(rows: SpreadsheetValue[][]): MasseSalarialeWorkbookImport { + const salaries: MasseSalarialeImportSalarie[] = []; + const remunerations: MasseSalarialeImportRemuneration[] = []; + + for (const row of rows.slice(5)) { + // SheetJS supprime la cellule A vide de certaines lignes. On repère donc + // la présence effective de cette cellule plutôt que de supposer un index. + const originalColumn = (excelColumn: number) => row[excelColumn + (row[0] === null || row[0] === undefined ? 0 : -1)]; + const matricule = text(originalColumn(1)); + const nom = text(originalColumn(2)); + const prenom = text(originalColumn(3)); + const poste = text(originalColumn(4)); + const dateEmbauche = isoDate(originalColumn(5)); + if (!matricule || !nom || !prenom || !poste || !dateEmbauche) continue; + + salaries.push({ matricule, nom, prenom, poste, dateEmbauche }); + const addForecast = (annee: number, startIndex: number, tauxChargeBps: number | null, periodeReference: string) => { + const brutHorsAstreinte = cents(originalColumn(startIndex)); + const brutAvecPrimes = cents(originalColumn(startIndex + 1)); + const primeAstreinte = cents(originalColumn(startIndex + 2)); + const brutMensuel = cents(originalColumn(startIndex + 3)); + const evolution = cents(originalColumn(startIndex + 5)); + const prime = annee === 2027 ? cents(originalColumn(startIndex + 6)) : 0; + if (brutHorsAstreinte === 0 && brutAvecPrimes === 0 && brutMensuel === 0) return; + + remunerations.push({ + matricule, + annee, + nature: "previsionnel", + salaireAnnuelBrutHorsPrimesCents: brutHorsAstreinte, + salaireAnnuelBrutAvecPrimesCents: brutAvecPrimes, + salaireMensuelBrutHorsPrimesCents: brutMensuel, + tauxChargeBps, + primeAstreinteCents: primeAstreinte, + primeCents: prime, + evolutionCents: evolution, + primeReferenceCents: prime, + evolutionReferenceCents: evolution, + periodeReference, + statut: annee === 2025 ? "annuel" : "previsionnel", + source: "fichier_modele_2026-08-28", + }); + }; + + addForecast(2025, 6, bps(originalColumn(10)), "2025-12"); + addForecast(2026, 12, bps(originalColumn(16)), "2026-08"); + addForecast(2027, 18, null, "2027-01"); + } + + return { salaries, remunerations }; +} diff --git a/server/routers.ts b/server/routers.ts index 6e4497c..6da6558 100644 --- a/server/routers.ts +++ b/server/routers.ts @@ -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>(); + + 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 = []; + 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({ diff --git a/todo.md b/todo.md index bcc0b54..b9c9ba8 100644 --- a/todo.md +++ b/todo.md @@ -220,3 +220,16 @@ - [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