diff --git a/client/src/App.tsx b/client/src/App.tsx index 5533ba3..049caba 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -20,6 +20,7 @@ import SoinsSante from "./pages/SoinsSante"; import StExupery from "./pages/StExupery"; import TableauBordFinance from "./pages/TableauBordFinance"; import MasseSalariale from "./pages/MasseSalariale"; +import Salaires from "./pages/Salaires"; import { useAuth } from "./_core/hooks/useAuth"; import { DashboardLayoutSkeleton } from "./components/DashboardLayoutSkeleton"; @@ -96,6 +97,9 @@ function Router() { + + + diff --git a/client/src/components/AppSidebar.tsx b/client/src/components/AppSidebar.tsx index 4ac7e7e..47cbc0f 100644 --- a/client/src/components/AppSidebar.tsx +++ b/client/src/components/AppSidebar.tsx @@ -135,6 +135,7 @@ const NAV_SECTIONS: NavSection[] = [ children: [ { id: 'santinova', label: 'Vue SANTINOVA', icon: Heart, path: '/santinova' }, { id: 'masse-salariale', label: 'Masse salariale', icon: Banknote, path: '/masse-salariale', adminOnly: true }, + { id: 'salaires', label: 'Salaires', icon: Banknote, path: '/salaires', adminOnly: true }, ], }, { diff --git a/client/src/pages/Salaires.tsx b/client/src/pages/Salaires.tsx new file mode 100644 index 0000000..a11c109 --- /dev/null +++ b/client/src/pages/Salaires.tsx @@ -0,0 +1,226 @@ +import { useMemo, useRef, useState } from "react"; +import { toast } from "sonner"; +import { + Archive, + Banknote, + CalendarDays, + CircleAlert, + FileText, + Loader2, + LockKeyhole, + ShieldCheck, + TrendingDown, + TrendingUp, + Upload, + Users, +} from "lucide-react"; +import { AppSidebar } from "../components/AppSidebar"; +import { trpc } from "../lib/trpc"; + +const MONTHS = [ + "Janvier", "Février", "Mars", "Avril", "Mai", "Juin", + "Juillet", "Août", "Septembre", "Octobre", "Novembre", "Décembre", +]; + +function formatEuros(cents?: number | null) { + if (cents === null || cents === undefined) return "—"; + return new Intl.NumberFormat("fr-FR", { + style: "currency", + currency: "EUR", + minimumFractionDigits: 0, + maximumFractionDigits: 0, + }).format(cents / 100); +} + +function formatPercent(percent?: number | null) { + if (percent === null || percent === undefined) return "—"; + return `${percent.toLocaleString("fr-FR", { maximumFractionDigits: 2 })} %`; +} + +function extractionStatusLabel(status: string) { + if (status === "ready") return { label: "Indexé", className: "bg-emerald-100 text-emerald-700" }; + if (status === "partiel") return { label: "Partiel", className: "bg-amber-100 text-amber-800" }; + return { label: "À contrôler", className: "bg-rose-100 text-rose-700" }; +} + +/** + * Module administrateur : les données visibles sont l'index métier réduit des + * bulletins. Les PDFs restent chiffrés dans le stockage persistant et ne sont + * servis qu'après un nouveau contrôle serveur de la session. + */ +export default function Salaires() { + const [sidebarCollapsed, setSidebarCollapsed] = useState(false); + const now = useMemo(() => new Date(), []); + const [annee, setAnnee] = useState(now.getFullYear()); + const [mois, setMois] = useState(now.getMonth() + 1); + const [selectedFile, setSelectedFile] = useState(null); + const [isUploading, setIsUploading] = useState(false); + const [reindexingId, setReindexingId] = useState(null); + const inputRef = useRef(null); + const utils = trpc.useUtils(); + const filters = useMemo(() => ({ annee, mois }), [annee, mois]); + const { data, isLoading, isError } = trpc.salaires.list.useQuery(filters); + const { data: periods = [] } = trpc.salaires.periods.useQuery(); + const { data: security } = trpc.salaires.getSecurityStatus.useQuery(); + + const years = useMemo(() => { + const options = new Set([now.getFullYear(), ...periods.map((period) => period.annee)]); + return Array.from(options).sort((a, b) => b - a); + }, [now, periods]); + + const uploadLiasse = async () => { + if (!selectedFile) { + toast.error("Sélectionnez une liasse PDF avant l'import."); + return; + } + if (selectedFile.type !== "application/pdf" && !selectedFile.name.toLowerCase().endsWith(".pdf")) { + toast.error("Le fichier sélectionné doit être un PDF."); + return; + } + if (!security?.ready) { + toast.error("Le chiffrement sécurisé est indisponible : l'import est bloqué."); + return; + } + + const form = new FormData(); + form.append("annee", String(annee)); + form.append("mois", String(mois)); + form.append("file", selectedFile); + setIsUploading(true); + try { + const response = await fetch("/api/salaires/liasses", { method: "POST", body: form, credentials: "include" }); + const payload = await response.json().catch(() => ({})) as { bulletins?: number; avertissement?: string | null; error?: string }; + if (!response.ok) throw new Error(payload.error ?? "L'import n'a pas pu être effectué."); + toast.success(`Liasse archivée de façon chiffrée — ${payload.bulletins ?? 0} bulletin(s) indexé(s).`); + if (payload.avertissement) toast.warning(payload.avertissement); + setSelectedFile(null); + if (inputRef.current) inputRef.current.value = ""; + await Promise.all([utils.salaires.list.invalidate(), utils.salaires.periods.invalidate()]); + } catch (error) { + toast.error(error instanceof Error ? error.message : "L'import sécurisé a échoué."); + } finally { + setIsUploading(false); + } + }; + + const reindexLiasse = async (liasseId: number) => { + setReindexingId(liasseId); + try { + const response = await fetch(`/api/salaires/liasses/${liasseId}/reindex`, { method: "POST", credentials: "include" }); + const payload = await response.json().catch(() => ({})) as { bulletins?: number; avertissement?: string | null; error?: string }; + if (!response.ok) throw new Error(payload.error ?? "La réindexation a échoué."); + toast.success(`Index de la liasse actualisé — ${payload.bulletins ?? 0} bulletin(s) reconnu(s).`); + if (payload.avertissement) toast.warning(payload.avertissement); + await Promise.all([utils.salaires.list.invalidate(), utils.salaires.periods.invalidate()]); + } catch (error) { + toast.error(error instanceof Error ? error.message : "La réindexation a échoué."); + } finally { + setReindexingId(null); + } + }; + + const hasExistingPeriod = periods.some((period) => period.annee === annee && period.mois === mois); + + return ( +
+ setSidebarCollapsed((value) => !value)} /> +
+
+
+
+
+ +
+
+

Salaires — SANTINOVA

+

Liasses mensuelles chiffrées, index métier et comparatif M-1

+
+
+
+ Accès administrateur +
+
+
+ +
+ {!security?.ready && ( +
+ +

Import bloqué. Le chiffrement AES-256-GCM n'est pas disponible dans cet environnement. Aucun PDF n'est transmis sans chiffrement.

+
+ )} + +
+
+
+ Période affichée +
+ + +
+ Les montants proviennent uniquement de la liasse importée. +
+
+ +
+
+
+
+

Archiver la liasse de {MONTHS[mois - 1]} {annee}

Le PDF est chiffré avant envoi vers le stockage persistant hors conteneur.

+
+ {hasExistingPeriod && Période déjà archivée} +
+
+
+ + {selectedFile?.name ?? "Choisir la liasse PDF"}PDF uniquement, 25 Mo maximum 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" /> +
+ +
+
+ +
+ + + +
+ +
+
+
+

Salariés et évolution mensuelle

Comparaison avec le même salarié de la liasse M-1 ; les variations sont explicitées à partir des rubriques extraites.

+
+ {isLoading ?
: isError ? : data?.bulletins.length ? ( +
{data.bulletins.map((bulletin) => )}
SalariéPosteBase mensuelleBrut courantBrut M-1ÉcartExplicationPDF

{bulletin.nom} {bulletin.prenom}

{bulletin.matricule}

{bulletin.poste}{formatEuros(bulletin.brutMensuelCents)}{formatEuros(bulletin.brutAvecPrimesCents)}{formatEuros(bulletin.brutPrecedentCents)} 0 ? "text-emerald-700" : "text-muted-foreground"}`}>{formatEuros(bulletin.ecartBrutCents)}{formatPercent(bulletin.ecartBrutPourcentage)}{bulletin.explication}Voir
+ ) : } +
+ +
+

Liasses archivées

Chaque archive reste disponible après reconstruction ou changement de version de l'application.

+ {periods.length === 0 ? :
{periods.map((period) => { const status = extractionStatusLabel(period.statutExtraction); return

{MONTHS[period.mois - 1]} {period.annee}

{period.nomFichier}

{status.label}
{period.erreurExtraction ?? "Index complet."}
{period.statutExtraction !== "ready" && }Voir la liasse
; })}
} +
+
+
+
+ ); +} + +function StatCard({ icon: Icon, label, value, accent }: { icon: React.ElementType; label: string; value: string; accent: string }) { + return
{label}

{value}

; +} + +function DataNotice({ message }: { message: string }) { + return
{message}
; +} diff --git a/docs/salaires-securite.md b/docs/salaires-securite.md new file mode 100644 index 0000000..e5f8f52 --- /dev/null +++ b/docs/salaires-securite.md @@ -0,0 +1,25 @@ +# Module Salaires — sécurité et conservation + +Le module **Salaires** est réservé aux comptes de profil `admin`, à la fois dans la navigation, dans les routes clientes, dans les procédures métier et sur les deux routes binaires serveur. Les profils `standard` et `readonly` ne peuvent ni lister les données, ni importer, ni consulter une liasse. + +Les fichiers PDF sont chiffrés côté serveur avec AES-256-GCM avant leur 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. + +La consultation récupère le contenu chiffré exclusivement côté serveur, le déchiffre après contrôle de la session administrateur et retourne le PDF avec les entêtes `Cache-Control: no-store` et `X-Content-Type-Options: nosniff`. Le navigateur ne reçoit aucune URL de stockage ni paramètre de chiffrement. + +La page a été vérifiée dans une session administrateur le 29 août 2026 : la navigation SANTINOVA affiche l'entrée Salaires, le statut de chiffrement est opérationnel, les contrôles année/mois, la zone d'import PDF, les indicateurs, l'état vide et les archives sont rendus sans erreur visible. + +La validation bout en bout utilise une liasse mensuelle fournie. Elle portera sur l'archivage du fichier chiffré, l'index métier restreint, le filtrage par période, l'explication M-1 et la réouverture après redémarrage du serveur. + +Lors du premier import réel de mai 2026, l'archive chiffrée a été créée et est restée consultable, mais l'index a été signalé « à contrôler ». Le diagnostic a confirmé que l'extracteur PDF compactait la mise en page : 22 en-têtes de bulletin ont été détectés, mais les matricules étaient présentés sous la forme « Matricule : M 000… » et l'espace avant la civilité était réduit. Le parseur a été corrigé à partir de ces seules caractéristiques de structure, puis une réindexation sans remplacement du PDF a été ajoutée. + +Après correction, l'analyse de la liasse réelle a reconnu 18 bulletins sur 22, sans qu'aucun contenu d'adresse, d'IBAN ou de numéro de sécurité sociale ne soit détecté dans le poste. L'archive doit donc rester au statut `partiel` et signaler quatre fiches à contrôler, plutôt que d'inventer leurs montants. + +La réindexation de l'archive de mai 2026 a ensuite été exécutée dans une session administrateur sans nouveau téléversement ni remplacement du PDF. Elle a persisté l'index partiel de 18 bulletins et le statut d'avertissement adéquat. + +Le filtre sur mai 2026 a été vérifié dans l'interface administrateur : la liste affiche les 18 salariés indexés avec le poste, la base mensuelle, le brut courant, l'absence justifiée de référence M-1 et le lien vers la liasse contrôlée. Les quatre bulletins non indexés demeurent explicitement signalés par le statut partiel. + +Une seconde liasse réelle, pour juin 2026, a été archivée et indexée au même statut partiel. Le filtre de juin affiche les 18 enregistrements indexés et compare chaque brut à celui de mai : les écarts et leurs explications reposent sur la variation des seules astreintes et des autres éléments inclus dans le brut. Les variations non reconnues restent explicitement signalées, sans valeur inventée. + +La consultation réelle de la liasse de juin a retourné un document dont la signature est `%PDF-`, avec `Content-Type: application/pdf`, `Cache-Control: no-store, private` et `X-Content-Type-Options: nosniff`. Aucune URL de stockage n'a été renvoyée au navigateur. + +Après un redémarrage complet du serveur de développement, les deux archives mensuelles et leurs statuts d'indexation étaient toujours présents dans l'interface. La relecture de la liasse de juin a de nouveau retourné un PDF valide, avec l'entête `no-store`. Cette vérification confirme que le fichier chiffré est lu depuis le stockage persistant extérieur au processus applicatif, et non depuis le système de fichiers d'un conteneur. diff --git a/drizzle/0008_living_silver_surfer.sql b/drizzle/0008_living_silver_surfer.sql new file mode 100644 index 0000000..2224771 --- /dev/null +++ b/drizzle/0008_living_silver_surfer.sql @@ -0,0 +1,36 @@ +CREATE TABLE `salaires_bulletins` ( + `id` int AUTO_INCREMENT NOT NULL, + `liasseId` int NOT NULL, + `matricule` varchar(32) NOT NULL, + `nom` varchar(150) NOT NULL, + `prenom` varchar(150) NOT NULL, + `poste` varchar(255) NOT NULL, + `brutMensuelCents` int NOT NULL, + `brutAvecPrimesCents` int NOT NULL, + `primeAstreinteCents` int NOT NULL DEFAULT 0, + `explicationEcart` text, + `numeroPage` int, + `createdAt` timestamp NOT NULL DEFAULT (now()), + `updatedAt` timestamp NOT NULL DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP, + CONSTRAINT `salaires_bulletins_id` PRIMARY KEY(`id`), + CONSTRAINT `salaires_bulletins_liasse_matricule_idx` UNIQUE(`liasseId`,`matricule`) +); +--> statement-breakpoint +CREATE TABLE `salaires_liasses` ( + `id` int AUTO_INCREMENT NOT NULL, + `annee` int NOT NULL, + `mois` int NOT NULL, + `nomFichier` varchar(255) NOT NULL, + `stockageKey` varchar(512) NOT NULL, + `empreinteSha256` varchar(64) NOT NULL, + `tailleOctets` int NOT NULL, + `ivBase64` varchar(32) NOT NULL, + `authTagBase64` varchar(32) NOT NULL, + `versionCle` int NOT NULL DEFAULT 1, + `importePar` int NOT NULL, + `importeLe` timestamp NOT NULL DEFAULT (now()), + CONSTRAINT `salaires_liasses_id` PRIMARY KEY(`id`), + CONSTRAINT `salaires_liasses_stockageKey_unique` UNIQUE(`stockageKey`), + CONSTRAINT `salaires_liasses_empreinteSha256_unique` UNIQUE(`empreinteSha256`), + CONSTRAINT `salaires_liasses_periode_empreinte_idx` UNIQUE(`annee`,`mois`,`empreinteSha256`) +); diff --git a/drizzle/0009_white_stardust.sql b/drizzle/0009_white_stardust.sql new file mode 100644 index 0000000..b1d6a17 --- /dev/null +++ b/drizzle/0009_white_stardust.sql @@ -0,0 +1 @@ +ALTER TABLE `salaires_liasses` ADD CONSTRAINT `salaires_liasses_periode_idx` UNIQUE(`annee`,`mois`); \ No newline at end of file diff --git a/drizzle/0010_brainy_oracle.sql b/drizzle/0010_brainy_oracle.sql new file mode 100644 index 0000000..0847e53 --- /dev/null +++ b/drizzle/0010_brainy_oracle.sql @@ -0,0 +1,2 @@ +ALTER TABLE `salaires_liasses` ADD `statutExtraction` varchar(32) DEFAULT 'ready' NOT NULL;--> statement-breakpoint +ALTER TABLE `salaires_liasses` ADD `erreurExtraction` text; \ No newline at end of file diff --git a/drizzle/meta/0008_snapshot.json b/drizzle/meta/0008_snapshot.json new file mode 100644 index 0000000..961b5c9 --- /dev/null +++ b/drizzle/meta/0008_snapshot.json @@ -0,0 +1,1449 @@ +{ + "version": "5", + "dialect": "mysql", + "id": "af577c42-3fdf-4389-b718-581d3c8ca687", + "prevId": "7c1a4a03-f900-42db-8845-2ae5a712b39a", + "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 + }, + "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": true, + "autoincrement": false + }, + "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_idx": { + "name": "masse_salariale_salarie_annee_idx", + "columns": [ + "salarieId", + "annee" + ], + "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 + }, + "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_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/0009_snapshot.json b/drizzle/meta/0009_snapshot.json new file mode 100644 index 0000000..85e17ba --- /dev/null +++ b/drizzle/meta/0009_snapshot.json @@ -0,0 +1,1457 @@ +{ + "version": "5", + "dialect": "mysql", + "id": "f6afba51-cb30-41dc-b1c5-b505f8b830fb", + "prevId": "af577c42-3fdf-4389-b718-581d3c8ca687", + "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 + }, + "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": true, + "autoincrement": false + }, + "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_idx": { + "name": "masse_salariale_salarie_annee_idx", + "columns": [ + "salarieId", + "annee" + ], + "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 + }, + "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/0010_snapshot.json b/drizzle/meta/0010_snapshot.json new file mode 100644 index 0000000..be6871c --- /dev/null +++ b/drizzle/meta/0010_snapshot.json @@ -0,0 +1,1472 @@ +{ + "version": "5", + "dialect": "mysql", + "id": "bad6983d-6c4f-49a9-806b-f42472f06063", + "prevId": "f6afba51-cb30-41dc-b1c5-b505f8b830fb", + "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 + }, + "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": true, + "autoincrement": false + }, + "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_idx": { + "name": "masse_salariale_salarie_annee_idx", + "columns": [ + "salarieId", + "annee" + ], + "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 a882c8d..eaa9c2b 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -57,6 +57,27 @@ "when": 1787833910943, "tag": "0007_rich_tony_stark", "breakpoints": true + }, + { + "idx": 8, + "version": "5", + "when": 1788040817404, + "tag": "0008_living_silver_surfer", + "breakpoints": true + }, + { + "idx": 9, + "version": "5", + "when": 1788041511204, + "tag": "0009_white_stardust", + "breakpoints": true + }, + { + "idx": 10, + "version": "5", + "when": 1788041740554, + "tag": "0010_brainy_oracle", + "breakpoints": true } ] } \ No newline at end of file diff --git a/drizzle/schema.ts b/drizzle/schema.ts index dead1eb..a768b73 100644 --- a/drizzle/schema.ts +++ b/drizzle/schema.ts @@ -265,3 +265,64 @@ export const masseSalarialeEvolutions = mysqlTable("masse_salariale_evolutions", export type MasseSalarialeEvolution = typeof masseSalarialeEvolutions.$inferSelect; export type InsertMasseSalarialeEvolution = typeof masseSalarialeEvolutions.$inferInsert; + +// ───────────────────────────────────────────────────────────────────────────── +// SALAIRES MENSUELS — Liasses PDF chiffrées et données extraites +// ───────────────────────────────────────────────────────────────────────────── + +/** + * Une liasse représente un dépôt mensuel. Le fichier est chiffré avant envoi + * vers le stockage persistant ; la base ne conserve ni octet de PDF ni clé. + */ +export const salairesLiasses = mysqlTable("salaires_liasses", { + id: int("id").autoincrement().primaryKey(), + annee: int("annee").notNull(), + mois: int("mois").notNull(), + nomFichier: varchar("nomFichier", { length: 255 }).notNull(), + stockageKey: varchar("stockageKey", { length: 512 }).notNull().unique(), + empreinteSha256: varchar("empreinteSha256", { length: 64 }).notNull().unique(), + tailleOctets: int("tailleOctets").notNull(), + /** Paramètres AES-256-GCM nécessaires au déchiffrement côté serveur. */ + ivBase64: varchar("ivBase64", { length: 32 }).notNull(), + authTagBase64: varchar("authTagBase64", { length: 32 }).notNull(), + versionCle: int("versionCle").notNull().default(1), + /** `ready`, `partiel` ou `a_controler` : une liasse est conservée même si l'extraction est incomplète. */ + statutExtraction: varchar("statutExtraction", { length: 32 }).notNull().default("ready"), + erreurExtraction: text("erreurExtraction"), + importePar: int("importePar").notNull(), + importeLe: timestamp("importeLe").defaultNow().notNull(), +}, (t) => ({ + /** Une liasse unique par mois : toutes les fiches mensuelles restent ainsi cohérentes. */ + uniqPeriode: uniqueIndex("salaires_liasses_periode_idx").on(t.annee, t.mois), + /** Empêche de traiter deux fois le même fichier sous une période distincte. */ + uniqPeriodeEmpreinte: uniqueIndex("salaires_liasses_periode_empreinte_idx").on(t.annee, t.mois, t.empreinteSha256), +})); + +export type SalaireLiasse = typeof salairesLiasses.$inferSelect; +export type InsertSalaireLiasse = typeof salairesLiasses.$inferInsert; + +/** + * Données utiles par bulletin. `explicationEcart` conserve uniquement les + * rubriques de rémunération nécessaires à expliquer une variation mensuelle. + */ +export const salairesBulletins = mysqlTable("salaires_bulletins", { + id: int("id").autoincrement().primaryKey(), + liasseId: int("liasseId").notNull(), + matricule: varchar("matricule", { length: 32 }).notNull(), + nom: varchar("nom", { length: 150 }).notNull(), + prenom: varchar("prenom", { length: 150 }).notNull(), + poste: varchar("poste", { length: 255 }).notNull(), + brutMensuelCents: int("brutMensuelCents").notNull(), + brutAvecPrimesCents: int("brutAvecPrimesCents").notNull(), + primeAstreinteCents: int("primeAstreinteCents").notNull().default(0), + explicationEcart: text("explicationEcart"), + numeroPage: int("numeroPage"), + createdAt: timestamp("createdAt").defaultNow().notNull(), + updatedAt: timestamp("updatedAt").defaultNow().onUpdateNow().notNull(), +}, (t) => ({ + /** Un seul bulletin indexé par matricule dans une liasse donnée. */ + uniqLiasseMatricule: uniqueIndex("salaires_bulletins_liasse_matricule_idx").on(t.liasseId, t.matricule), +})); + +export type SalaireBulletin = typeof salairesBulletins.$inferSelect; +export type InsertSalaireBulletin = typeof salairesBulletins.$inferInsert; diff --git a/package.json b/package.json index 41f60ae..a7f2f0c 100644 --- a/package.json +++ b/package.json @@ -62,9 +62,11 @@ "input-otp": "^1.4.2", "jose": "6.1.0", "lucide-react": "^0.453.0", + "multer": "2.0.2", "mysql2": "^3.15.0", "nanoid": "^5.1.5", "next-themes": "^0.4.6", + "pdf-parse": "1.1.1", "react": "^19.2.1", "react-day-picker": "^9.11.1", "react-dom": "^19.2.1", @@ -88,9 +90,11 @@ "@types/bcryptjs": "^3.0.0", "@types/express": "4.17.21", "@types/google.maps": "^3.58.1", + "@types/multer": "2.0.0", "@types/node": "^24.7.0", "@types/react": "^19.2.1", "@types/react-dom": "^19.2.1", + "@types/supertest": "6.0.3", "@vitejs/plugin-react": "^5.0.4", "add": "^2.0.6", "autoprefixer": "^10.4.20", @@ -99,6 +103,7 @@ "pnpm": "^10.15.1", "postcss": "^8.4.47", "prettier": "^3.6.2", + "supertest": "7.1.4", "tailwindcss": "^4.1.14", "tsx": "^4.19.1", "tw-animate-css": "^1.4.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5f8d4b8..5a66ed8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -160,6 +160,9 @@ importers: lucide-react: specifier: ^0.453.0 version: 0.453.0(react@19.2.1) + multer: + specifier: 2.0.2 + version: 2.0.2 mysql2: specifier: ^3.15.0 version: 3.22.5(@types/node@24.7.0) @@ -169,6 +172,9 @@ importers: next-themes: specifier: ^0.4.6 version: 0.4.6(react-dom@19.2.1(react@19.2.1))(react@19.2.1) + pdf-parse: + specifier: 1.1.1 + version: 1.1.1 react: specifier: ^19.2.1 version: 19.2.1 @@ -233,6 +239,9 @@ importers: '@types/google.maps': specifier: ^3.58.1 version: 3.58.1 + '@types/multer': + specifier: 2.0.0 + version: 2.0.0 '@types/node': specifier: ^24.7.0 version: 24.7.0 @@ -242,6 +251,9 @@ importers: '@types/react-dom': specifier: ^19.2.1 version: 19.2.1(@types/react@19.2.1) + '@types/supertest': + specifier: 6.0.3 + version: 6.0.3 '@vitejs/plugin-react': specifier: ^5.0.4 version: 5.0.4(vite@7.1.9(@types/node@24.7.0)(jiti@2.6.1)(lightningcss@1.30.1)(tsx@4.20.6)) @@ -266,6 +278,9 @@ importers: prettier: specifier: ^3.6.2 version: 3.6.2 + supertest: + specifier: 7.1.4 + version: 7.1.4 tailwindcss: specifier: ^4.1.14 version: 4.1.14 @@ -1164,9 +1179,16 @@ packages: '@mermaid-js/parser@0.6.3': resolution: {integrity: sha512-lnjOhe7zyHjc+If7yT4zoedx2vo4sHaTmtkl1+or8BRTnCtDmcTpAjpzDSfCZrshM5bCoz0GyidzadJAH1xobA==} + '@noble/hashes@1.8.0': + resolution: {integrity: sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==} + engines: {node: ^14.21.3 || >=16} + '@nodable/entities@2.1.1': resolution: {integrity: sha512-Pig3HxDIoMgjdEH8OCf/dkcTmLFjJRjWuq8jSnklu284/TKOPibSRERmOykiwmyXTtv61mP+44f3GMx0tLAyjg==} + '@paralleldrive/cuid2@2.3.1': + resolution: {integrity: sha512-XO7cAxhnTZl0Yggq6jOgjiOHhbgcO4NqFqwSmQpjK3b6TEE6Uj/jfSk6wzYyemh3+I0sHirKSetjQwn5cZktFw==} + '@radix-ui/number@1.1.1': resolution: {integrity: sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g==} @@ -2099,6 +2121,9 @@ packages: '@types/connect@3.4.38': resolution: {integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==} + '@types/cookiejar@2.1.5': + resolution: {integrity: sha512-he+DHOWReW0nghN24E1WUqM0efK4kI9oTqDm6XmK8ZPe2djZ90BSNdGnIyCLzCPw7/pogPlGbzI2wHGGmi4O/Q==} + '@types/d3-array@3.2.2': resolution: {integrity: sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==} @@ -2225,12 +2250,18 @@ packages: '@types/mdast@4.0.4': resolution: {integrity: sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==} + '@types/methods@1.1.4': + resolution: {integrity: sha512-ymXWVrDiCxTBE3+RIrrP533E70eA+9qu7zdWoHuOmGujkYtzf4HQF96b8nwHLqhuf4ykX61IGRIB38CC6/sImQ==} + '@types/mime@1.3.5': resolution: {integrity: sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==} '@types/ms@2.1.0': resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==} + '@types/multer@2.0.0': + resolution: {integrity: sha512-C3Z9v9Evij2yST3RSBktxP9STm6OdMc5uR1xF1SGr98uv8dUlAL2hqwrZ3GVB3uyMyiegnscEK6PGtYvNrjTjw==} + '@types/node@24.7.0': resolution: {integrity: sha512-IbKooQVqUBrlzWTi79E8Fw78l8k1RNtlDDNWsFZs7XonuQSJ8oNYfEeclhprUldXISRMLzBpILuKgPlIxm+/Yw==} @@ -2257,6 +2288,12 @@ packages: '@types/serve-static@1.15.9': resolution: {integrity: sha512-dOTIuqpWLyl3BBXU3maNQsS4A3zuuoYRNIvYSxxhebPfXg2mzWQEPne/nlJ37yOse6uGgR386uTpdsx4D0QZWA==} + '@types/superagent@8.1.11': + resolution: {integrity: sha512-KA7srSW/HENDtOw9DOqaFLgWuMqN9WgjEw62lh9dpvRaZDkhdOkazASd7X7i2eMUYLHa1U37ZttnePsH5zTDHw==} + + '@types/supertest@6.0.3': + resolution: {integrity: sha512-8WzXq62EXFhJ7QsH3Ocb/iKQ/Ty9ZVWnVzoTKc9tyyFRRF3a74Tk2+TLFgaFFw364Ere+npzHKEJ6ga2LzIL7w==} + '@types/trusted-types@2.0.7': resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==} @@ -2323,6 +2360,9 @@ packages: anynum@1.0.0: resolution: {integrity: sha512-xjR9/zBVnUOP6ztMIIgShjsxui80nQUQH+5xJnvrYLs+90bF25/KJqaAi8mk+B4RDtX1Nspi6fmp4YTEts8SfA==} + append-field@1.0.0: + resolution: {integrity: sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==} + aria-hidden@1.2.6: resolution: {integrity: sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==} engines: {node: '>=10'} @@ -2330,6 +2370,9 @@ packages: array-flatten@1.1.1: resolution: {integrity: sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==} + asap@2.0.6: + resolution: {integrity: sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==} + assertion-error@2.0.1: resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} engines: {node: '>=12'} @@ -2377,6 +2420,10 @@ packages: buffer-from@1.1.2: resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} + busboy@1.6.0: + resolution: {integrity: sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==} + engines: {node: '>=10.16.0'} + bytes@3.1.2: resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} engines: {node: '>= 0.8'} @@ -2467,6 +2514,13 @@ packages: resolution: {integrity: sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==} engines: {node: '>= 12'} + component-emitter@1.3.1: + resolution: {integrity: sha512-T0+barUSQRTUQASh8bx02dl+DhF54GtIDY13Y3m9oWTklKbb3Wv974meRpeZ3lp1JpLVECWWNHC4vaG2XHXouQ==} + + concat-stream@2.0.0: + resolution: {integrity: sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==} + engines: {'0': node >= 6.0} + confbox@0.1.8: resolution: {integrity: sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==} @@ -2495,6 +2549,9 @@ packages: resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==} engines: {node: '>=18'} + cookiejar@2.1.4: + resolution: {integrity: sha512-LDx6oHrK+PhzLKJU9j5S7/Y3jM/mUHvD/DeI1WQmJn652iPC5Y4TBzC9l+5OMOXlyTTA+SmVUPm0HQUwpD5Jqw==} + copy-anything@3.0.5: resolution: {integrity: sha512-yCEafptTtb4bk7GLEQoM8KVJpxAfdBJYaXyzQEgQQQgYrZiDp8SJmGKlYza6CYjEDNstAdNdKA3UuoULlEbS6w==} engines: {node: '>=12.13'} @@ -2691,6 +2748,14 @@ packages: supports-color: optional: true + debug@3.2.7: + resolution: {integrity: sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + debug@4.4.3: resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} engines: {node: '>=6.0'} @@ -2743,6 +2808,9 @@ packages: devlop@1.1.0: resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==} + dezalgo@1.0.4: + resolution: {integrity: sha512-rXSP0bf+5n0Qonsb+SVVfNfIsimO4HEtmnIpPHY8Q1UCzKlQrDMfdobr8nJOOsRgWCyMRqeSBQzmWUMq7zvVig==} + dom-helpers@5.2.1: resolution: {integrity: sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==} @@ -2972,6 +3040,9 @@ packages: resolution: {integrity: sha512-6rxyATwPCkaFIL3JLqw8qXqMpIZ942pTX/tbQFkRsDGblS8tNGtlUauA/+mt6RUfqn/4MoEr+WDkYoIQbibWuQ==} engines: {node: '>=6.0.0'} + fast-safe-stringify@2.1.1: + resolution: {integrity: sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==} + fast-xml-builder@1.2.0: resolution: {integrity: sha512-00aAWieqff+ZJhsXA4g1g7M8k+7AYoMUUHF+/zFb5U6Uv/P0Vl4QZo84/IcufzYalLuEj9928bXN9PbbFzMF0Q==} @@ -3005,6 +3076,14 @@ packages: resolution: {integrity: sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==} engines: {node: '>= 6'} + form-data@4.0.6: + resolution: {integrity: sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==} + engines: {node: '>= 6'} + + formidable@3.5.4: + resolution: {integrity: sha512-YikH+7CUTOtP44ZTnUhR7Ic2UASBPOqmaRkRKxRbywPTe5VxF7RRCck4af9wutiZ/QKM5nME9Bie2fFaPz5Gug==} + engines: {node: '>=14.0.0'} + forwarded@0.2.0: resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} engines: {node: '>= 0.6'} @@ -3090,6 +3169,10 @@ packages: resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} engines: {node: '>= 0.4'} + hasown@2.0.4: + resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==} + engines: {node: '>= 0.4'} + hast-util-from-dom@5.0.1: resolution: {integrity: sha512-N+LqofjR2zuzTjCPzyDUdSshy4Ma6li7p/c3pA78uTwzFgENbgbUrm2ugwsOdcjI1muO+o6Dgzp9p8WHtn/39Q==} @@ -3518,6 +3601,14 @@ packages: engines: {node: '>=4'} hasBin: true + mime@2.6.0: + resolution: {integrity: sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==} + engines: {node: '>=4.0.0'} + hasBin: true + + minimist@1.2.8: + resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} + minipass@7.1.2: resolution: {integrity: sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==} engines: {node: '>=16 || 14 >=14.17'} @@ -3529,6 +3620,10 @@ packages: mitt@3.0.1: resolution: {integrity: sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==} + mkdirp@0.5.6: + resolution: {integrity: sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==} + hasBin: true + mlly@1.8.0: resolution: {integrity: sha512-l8D9ODSRWLe2KHJSifWGwBqpTZXIXTeo8mlKjY+E2HAakaTeNpqAyBZ8GSqLzHgw4XmHmC8whvpjJNMbFZN7/g==} @@ -3547,6 +3642,10 @@ packages: ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + multer@2.0.2: + resolution: {integrity: sha512-u7f2xaZ/UG8oLXHvtF/oWTRvT44p9ecwBBqTwgJVq0+4BW1g8OW01TyMEGWBHbyMOYVHXslaut7qEQ1meATXgw==} + engines: {node: '>= 10.16.0'} + mysql2@3.22.5: resolution: {integrity: sha512-95uZ2TrPWAZdwpB3vvvDbmEMcNG8yIeNCyu6GUcr/QnWEE/wXm7+mhOCsdQfWQDTV7qYT/PDUZ4U4UPP4AsXqQ==} engines: {node: '>= 8.0'} @@ -3577,6 +3676,9 @@ packages: react: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc react-dom: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc + node-ensure@0.0.0: + resolution: {integrity: sha512-DRI60hzo2oKN1ma0ckc6nQWlHU69RH6xN0sjQTjMpChPfTYvKZdcQFfdYK2RWbJcKyUizSIy/l8OTGxMAM1QDw==} + node-releases@2.0.23: resolution: {integrity: sha512-cCmFDMSm26S6tQSDpBCg/NR8NENrVPhAJSf+XbxBG4rPFaaonlEoE9wHQmun+cls499TQGSb7ZyPBRlzgKfpeg==} @@ -3596,6 +3698,9 @@ packages: resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==} engines: {node: '>= 0.8'} + once@1.4.0: + resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + oniguruma-parser@0.12.1: resolution: {integrity: sha512-8Unqkvk1RYc6yq2WBYRj4hdnsAxVze8i7iPfQr8e4uSP3tRv0rpZcbGUDvxfQQcdwHt/e9PrMvGCsa8OqG9X3w==} @@ -3635,6 +3740,10 @@ packages: resolution: {integrity: sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==} engines: {node: '>= 14.16'} + pdf-parse@1.1.1: + resolution: {integrity: sha512-v6ZJ/efsBpGrGGknjtq9J/oC8tZWq0KWL5vQrk2GlzLEQPUDB1ex+13Rmidl1neNN358Jn9EHZw5y07FFtaC7A==} + engines: {node: '>=6.8.1'} + picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} @@ -3695,6 +3804,10 @@ packages: resolution: {integrity: sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg==} engines: {node: '>=0.6'} + qs@6.15.3: + resolution: {integrity: sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==} + engines: {node: '>=0.6'} + quansync@0.2.11: resolution: {integrity: sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA==} @@ -3791,6 +3904,10 @@ packages: resolution: {integrity: sha512-DGrYcCWK7tvYMnWh79yrPHt+vdx9tY+1gPZa7nJQtO/p8bLTDaHp4dzwEhQB7pZ4Xe3ok4XKuEPrVuc+wlpkmw==} engines: {node: '>=0.10.0'} + readable-stream@3.6.2: + resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==} + engines: {node: '>= 6'} + recharts-scale@0.4.5: resolution: {integrity: sha512-kivNFO+0OcUNu7jQquLXAxz1FIwZj8nrj+YkOKc5694NbjCvcT6aSZiIzNzd2Kul4o4rTto8QVR9lMNtxD4G1w==} @@ -3886,6 +4003,10 @@ packages: resolution: {integrity: sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==} engines: {node: '>= 0.4'} + side-channel-list@1.0.1: + resolution: {integrity: sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==} + engines: {node: '>= 0.4'} + side-channel-map@1.0.1: resolution: {integrity: sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==} engines: {node: '>= 0.4'} @@ -3898,6 +4019,10 @@ packages: resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==} engines: {node: '>= 0.4'} + side-channel@1.1.1: + resolution: {integrity: sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==} + engines: {node: '>= 0.4'} + siginfo@2.0.0: resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} @@ -3944,6 +4069,13 @@ packages: peerDependencies: react: ^18.0.0 || ^19.0.0 + streamsearch@1.1.0: + resolution: {integrity: sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==} + engines: {node: '>=10.0.0'} + + string_decoder@1.3.0: + resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==} + stringify-entities@4.0.4: resolution: {integrity: sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==} @@ -3959,10 +4091,18 @@ packages: stylis@4.3.6: resolution: {integrity: sha512-yQ3rwFWRfwNUY7H5vpU0wfdkNSnvnJinhF9830Swlaxl03zsOjCfmX0ugac+3LtK0lYSgwL/KXc8oYL3mG4YFQ==} + superagent@10.3.0: + resolution: {integrity: sha512-B+4Ik7ROgVKrQsXTV0Jwp2u+PXYLSlqtDAhYnkkD+zn3yg8s/zjA2MeGayPoY/KICrbitwneDHrjSotxKL+0XQ==} + engines: {node: '>=14.18.0'} + superjson@1.13.3: resolution: {integrity: sha512-mJiVjfd2vokfDxsQPOwJ/PtanO87LhpYY88ubI5dUB1Ab58Txbyje3+jpm+/83R/fevaq/107NNhtYBLuoTrFg==} engines: {node: '>=10'} + supertest@7.1.4: + resolution: {integrity: sha512-tjLPs7dVyqgItVFirHYqe2T+MfWc2VOBQ8QFKKbWTA3PU7liZR8zoSpAi/C1k1ilm9RsXIKYf197oap9wXGVYg==} + engines: {node: '>=14.18.0'} + tailwind-merge@3.3.1: resolution: {integrity: sha512-gBXpgUm/3rp1lMZZrM/w7D8GKqshif0zAymAhbCyIt8KMe+0v9DQ7cdYLR4FHH/cKpdTXb+A/tKKU3eolfsI+g==} @@ -4044,6 +4184,9 @@ packages: resolution: {integrity: sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==} engines: {node: '>= 0.6'} + typedarray@0.0.6: + resolution: {integrity: sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==} + typescript@5.9.3: resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} engines: {node: '>=14.17'} @@ -4292,6 +4435,9 @@ packages: peerDependencies: react: '>=16.8.0' + wrappy@1.0.2: + resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + xlsx@0.18.5: resolution: {integrity: sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==} engines: {node: '>=0.8'} @@ -4301,6 +4447,10 @@ packages: resolution: {integrity: sha512-k8KO9hrMyNk6tUWqUfkTEZbezRRpONVOzUTnc97VnCvyj6Tf9lyUR9EDAIeiVLv56jsMcoXEwjW8Kv5yPY52lw==} engines: {node: '>=16.0.0'} + xtend@4.0.2: + resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==} + engines: {node: '>=0.4'} + yallist@3.1.1: resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} @@ -5077,8 +5227,14 @@ snapshots: dependencies: langium: 3.3.1 + '@noble/hashes@1.8.0': {} + '@nodable/entities@2.1.1': {} + '@paralleldrive/cuid2@2.3.1': + dependencies: + '@noble/hashes': 1.8.0 + '@radix-ui/number@1.1.1': {} '@radix-ui/primitive@1.1.3': {} @@ -6017,6 +6173,8 @@ snapshots: dependencies: '@types/node': 24.7.0 + '@types/cookiejar@2.1.5': {} + '@types/d3-array@3.2.2': {} '@types/d3-axis@3.0.6': @@ -6174,10 +6332,16 @@ snapshots: dependencies: '@types/unist': 3.0.3 + '@types/methods@1.1.4': {} + '@types/mime@1.3.5': {} '@types/ms@2.1.0': {} + '@types/multer@2.0.0': + dependencies: + '@types/express': 4.17.21 + '@types/node@24.7.0': dependencies: undici-types: 7.14.0 @@ -6209,6 +6373,18 @@ snapshots: '@types/node': 24.7.0 '@types/send': 0.17.5 + '@types/superagent@8.1.11': + dependencies: + '@types/cookiejar': 2.1.5 + '@types/methods': 1.1.4 + '@types/node': 24.7.0 + form-data: 4.0.4 + + '@types/supertest@6.0.3': + dependencies: + '@types/methods': 1.1.4 + '@types/superagent': 8.1.11 + '@types/trusted-types@2.0.7': optional: true @@ -6283,12 +6459,16 @@ snapshots: anynum@1.0.0: {} + append-field@1.0.0: {} + aria-hidden@1.2.6: dependencies: tslib: 2.8.1 array-flatten@1.1.1: {} + asap@2.0.6: {} + assertion-error@2.0.1: {} asynckit@0.4.0: {} @@ -6348,6 +6528,10 @@ snapshots: buffer-from@1.1.2: {} + busboy@1.6.0: + dependencies: + streamsearch: 1.1.0 + bytes@3.1.2: {} cac@6.7.14: {} @@ -6435,6 +6619,15 @@ snapshots: commander@8.3.0: {} + component-emitter@1.3.1: {} + + concat-stream@2.0.0: + dependencies: + buffer-from: 1.1.2 + inherits: 2.0.4 + readable-stream: 3.6.2 + typedarray: 0.0.6 + confbox@0.1.8: {} confbox@0.2.2: {} @@ -6453,6 +6646,8 @@ snapshots: cookie@1.1.1: {} + cookiejar@2.1.4: {} + copy-anything@3.0.5: dependencies: is-what: 4.1.16 @@ -6665,6 +6860,10 @@ snapshots: dependencies: ms: 2.0.0 + debug@3.2.7: + dependencies: + ms: 2.1.3 + debug@4.4.3: dependencies: ms: 2.1.3 @@ -6699,6 +6898,11 @@ snapshots: dependencies: dequal: 2.0.3 + dezalgo@1.0.4: + dependencies: + asap: 2.0.6 + wrappy: 1.0.2 + dom-helpers@5.2.1: dependencies: '@babel/runtime': 7.28.4 @@ -6942,6 +7146,8 @@ snapshots: fast-equals@5.3.2: {} + fast-safe-stringify@2.1.1: {} + fast-xml-builder@1.2.0: dependencies: path-expression-matcher: 1.5.0 @@ -6980,6 +7186,20 @@ snapshots: hasown: 2.0.2 mime-types: 2.1.35 + form-data@4.0.6: + dependencies: + asynckit: 0.4.0 + combined-stream: 1.0.8 + es-set-tostringtag: 2.1.0 + hasown: 2.0.4 + mime-types: 2.1.35 + + formidable@3.5.4: + dependencies: + '@paralleldrive/cuid2': 2.3.1 + dezalgo: 1.0.4 + once: 1.4.0 + forwarded@0.2.0: {} frac@1.1.2: {} @@ -7050,6 +7270,10 @@ snapshots: dependencies: function-bind: 1.1.2 + hasown@2.0.4: + dependencies: + function-bind: 1.1.2 + hast-util-from-dom@5.0.1: dependencies: '@types/hast': 3.0.4 @@ -7750,6 +7974,10 @@ snapshots: mime@1.6.0: {} + mime@2.6.0: {} + + minimist@1.2.8: {} + minipass@7.1.2: {} minizlib@3.1.0: @@ -7758,6 +7986,10 @@ snapshots: mitt@3.0.1: {} + mkdirp@0.5.6: + dependencies: + minimist: 1.2.8 + mlly@1.8.0: dependencies: acorn: 8.15.0 @@ -7777,6 +8009,16 @@ snapshots: ms@2.1.3: {} + multer@2.0.2: + dependencies: + append-field: 1.0.0 + busboy: 1.6.0 + concat-stream: 2.0.0 + mkdirp: 0.5.6 + object-assign: 4.1.1 + type-is: 1.6.18 + xtend: 4.0.2 + mysql2@3.22.5(@types/node@24.7.0): dependencies: '@types/node': 24.7.0 @@ -7804,6 +8046,8 @@ snapshots: react: 19.2.1 react-dom: 19.2.1(react@19.2.1) + node-ensure@0.0.0: {} + node-releases@2.0.23: {} normalize-range@0.1.2: {} @@ -7816,6 +8060,10 @@ snapshots: dependencies: ee-first: 1.1.1 + once@1.4.0: + dependencies: + wrappy: 1.0.2 + oniguruma-parser@0.12.1: {} oniguruma-to-es@4.3.3: @@ -7854,6 +8102,13 @@ snapshots: pathval@2.0.1: {} + pdf-parse@1.1.1: + dependencies: + debug: 3.2.7 + node-ensure: 0.0.0 + transitivePeerDependencies: + - supports-color + picocolors@1.1.1: {} picomatch@4.0.3: {} @@ -7915,6 +8170,11 @@ snapshots: dependencies: side-channel: 1.1.0 + qs@6.15.3: + dependencies: + es-define-property: 1.0.1 + side-channel: 1.1.1 + quansync@0.2.11: {} range-parser@1.2.1: {} @@ -8017,6 +8277,12 @@ snapshots: react@19.2.1: {} + readable-stream@3.6.2: + dependencies: + inherits: 2.0.4 + string_decoder: 1.3.0 + util-deprecate: 1.0.2 + recharts-scale@0.4.5: dependencies: decimal.js-light: 2.5.1 @@ -8201,6 +8467,11 @@ snapshots: es-errors: 1.3.0 object-inspect: 1.13.4 + side-channel-list@1.0.1: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + side-channel-map@1.0.1: dependencies: call-bound: 1.0.4 @@ -8224,6 +8495,14 @@ snapshots: side-channel-map: 1.0.1 side-channel-weakmap: 1.0.2 + side-channel@1.1.1: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + side-channel-list: 1.0.1 + side-channel-map: 1.0.1 + side-channel-weakmap: 1.0.2 + siginfo@2.0.0: {} sonner@2.0.7(react-dom@19.2.1(react@19.2.1))(react@19.2.1): @@ -8274,6 +8553,12 @@ snapshots: - '@types/react' - supports-color + streamsearch@1.1.0: {} + + string_decoder@1.3.0: + dependencies: + safe-buffer: 5.2.1 + stringify-entities@4.0.4: dependencies: character-entities-html4: 2.1.0 @@ -8293,10 +8578,31 @@ snapshots: stylis@4.3.6: {} + superagent@10.3.0: + dependencies: + component-emitter: 1.3.1 + cookiejar: 2.1.4 + debug: 4.4.3 + fast-safe-stringify: 2.1.1 + form-data: 4.0.6 + formidable: 3.5.4 + methods: 1.1.2 + mime: 2.6.0 + qs: 6.15.3 + transitivePeerDependencies: + - supports-color + superjson@1.13.3: dependencies: copy-anything: 3.0.5 + supertest@7.1.4: + dependencies: + methods: 1.1.2 + superagent: 10.3.0 + transitivePeerDependencies: + - supports-color + tailwind-merge@3.3.1: {} tailwindcss-animate@1.0.7(tailwindcss@4.1.14): @@ -8364,6 +8670,8 @@ snapshots: media-typer: 0.3.0 mime-types: 2.1.35 + typedarray@0.0.6: {} + typescript@5.9.3: {} ufo@1.6.1: {} @@ -8612,6 +8920,8 @@ snapshots: regexparam: 3.0.0 use-sync-external-store: 1.6.0(react@19.2.1) + wrappy@1.0.2: {} + xlsx@0.18.5: dependencies: adler-32: 1.3.1 @@ -8624,6 +8934,8 @@ snapshots: xml-naming@0.1.0: {} + xtend@4.0.2: {} + yallist@3.1.1: {} yallist@5.0.0: {} diff --git a/scripts/diagnosePayrollExtraction.ts b/scripts/diagnosePayrollExtraction.ts new file mode 100644 index 0000000..7574c13 --- /dev/null +++ b/scripts/diagnosePayrollExtraction.ts @@ -0,0 +1,28 @@ +import fs from "node:fs"; +import { extractPayrollBulletins } from "../server/payrollParser"; +import { extractPayrollPdfText } from "../server/payrollPdfText"; + +const filePath = process.argv[2]; +if (!filePath) throw new Error("Chemin PDF requis"); + +const text = await extractPayrollPdfText(fs.readFileSync(filePath)); +const extraction = extractPayrollBulletins(text); +const lines = text.split(/\r?\n/); +const redactStructure = (line: string) => line.replace(/[A-Za-zÀ-ÿ]/g, "x").replace(/\d/g, "#").slice(0, 240); +const prohibitedPosteContent = extraction.bulletins.some((bulletin) => /iban|rib|n°\s*ss|adresse|fr\d{2}/i.test(bulletin.poste)); +console.log(JSON.stringify({ + bulletins: extraction.bulletins.length, + statutExtraction: extraction.statutExtraction, + hasExtractionWarning: Boolean(extraction.erreurExtraction), + containsProhibitedPosteContent: prohibitedPosteContent, + preservedPageSeparators: (text.match(/\f/g) ?? []).length, + bulletinMarkers: (text.match(/##BULLETIN##/g) ?? []).length, + matriculeMatchCount: (text.match(/Matricule\s*:\s*(?:M|N|N°)?\s*\d+/g) ?? []).length, + emploiSameLineCount: lines.filter((line) => /Emploi\s*:.*(?:Monsieur|Madame)\b/i.test(line)).length, + baseLineCount: lines.filter((line) => line.trim().startsWith("Salaire de base")).length, + grossLineCount: lines.filter((line) => line.trim().startsWith("Salaire brut")).length, + emploiVicinityStructures: lines + .flatMap((line, index) => /Emploi\s*:/i.test(line) ? lines.slice(index, index + 4) : []) + .slice(0, 8) + .map(redactStructure), +})); diff --git a/server/_core/index.ts b/server/_core/index.ts index 70a2540..1069313 100644 --- a/server/_core/index.ts +++ b/server/_core/index.ts @@ -6,6 +6,7 @@ import { createExpressMiddleware } from "@trpc/server/adapters/express"; import { registerOAuthRoutes } from "./oauth"; import { registerStorageProxy } from "./storageProxy"; import { appRouter } from "../routers"; +import { registerSalairesHttpRoutes } from "../salairesHttp"; import { createContext } from "./context"; import { serveStatic, setupVite } from "./vite"; @@ -36,6 +37,7 @@ async function startServer() { app.use(express.urlencoded({ limit: "50mb", extended: true })); registerStorageProxy(app); registerOAuthRoutes(app); + registerSalairesHttpRoutes(app); // tRPC API app.use( "/api/trpc", diff --git a/server/auth.local.test.ts b/server/auth.local.test.ts index 29cf0b4..cb2a60a 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(), + getSalairesMensuels: vi.fn(), }; }); @@ -434,3 +435,72 @@ describe("masseSalariale", () => { expect(db.setMasseSalarialeEvolution).not.toHaveBeenCalled(); }); }); + +// ─── Tests Salaires : sonde de chiffrement et confidentialité ───────────────── + +describe("salaires.getSecurityStatus", () => { + it("valide la clé de chiffrement configurée via une procédure administrateur", async () => { + const { ctx } = createAuthCtx({ role: "admin" }); + + await expect(appRouter.createCaller(ctx).salaires.getSecurityStatus()) + .resolves.toEqual({ ready: true, algorithm: "AES-256-GCM" }); + }); + + it("interdit la sonde de sécurité aux profils non administrateurs", async () => { + const { ctx } = createAuthCtx({ role: "standard" }); + + await expect(appRouter.createCaller(ctx).salaires.getSecurityStatus()).rejects.toThrow(); + }); +}); + +describe("salaires.list", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("retourne l'index métier et explique la variation uniquement à l'administrateur", async () => { + const { db } = await import("./db").then(m => ({ db: m })); + const mayLiasse = { + id: 9, annee: 2026, mois: 5, nomFichier: "mai.pdf", stockageKey: "secret-key", + empreinteSha256: "a".repeat(64), tailleOctets: 1200, ivBase64: "iv", authTagBase64: "tag", + versionCle: 1, statutExtraction: "ready", erreurExtraction: null, importePar: 1, importeLe: new Date(), + }; + const juneLiasse = { ...mayLiasse, id: 10, mois: 6, nomFichier: "juin.pdf", stockageKey: "secret-key-2" }; + const mayBulletin = { + id: 1, liasseId: 9, matricule: "000001", nom: "DUPONT", prenom: "TEST", poste: "Responsable", + brutMensuelCents: 300000, brutAvecPrimesCents: 320000, primeAstreinteCents: 20000, + explicationEcart: null, numeroPage: 1, createdAt: new Date(), updatedAt: new Date(), + }; + const juneBulletin = { ...mayBulletin, id: 2, liasseId: 10, brutAvecPrimesCents: 350000, primeAstreinteCents: 40000 }; + (db.getSalairesMensuels as ReturnType).mockResolvedValue({ + liasses: [juneLiasse, mayLiasse], + rows: [{ liasse: juneLiasse, bulletin: juneBulletin }, { liasse: mayLiasse, bulletin: mayBulletin }], + }); + + const { ctx } = createAuthCtx({ role: "admin" }); + const result = await appRouter.createCaller(ctx).salaires.list({ annee: 2026, mois: 6 }); + + expect(result.indicateurs).toEqual({ nombreSalaries: 1, totalBrutCents: 350000, totalEcartCents: 30000 }); + expect(result.bulletins[0]).toMatchObject({ ecartBrutCents: 30000, brutPrecedentCents: 320000 }); + expect(result.bulletins[0]?.explication).toContain("astreintes"); + expect(JSON.stringify(result)).not.toContain("secret-key"); + expect(JSON.stringify(result)).not.toContain("empreinteSha256"); + expect(result.liasses[0]?.pdfPath).toBe("/api/salaires/liasses/10/pdf"); + }); + + it("interdit l'index salarial aux profils standard et lecture seule", async () => { + const standard = createAuthCtx({ role: "standard" }); + const readonly = createAuthCtx({ role: "readonly" }); + + await expect(appRouter.createCaller(standard.ctx).salaires.list({})).rejects.toThrow(); + await expect(appRouter.createCaller(readonly.ctx).salaires.list({})).rejects.toThrow(); + }); + + it("rejette un mois invalide avant tout accès aux données", async () => { + const { db } = await import("./db").then(m => ({ db: m })); + const { ctx } = createAuthCtx({ role: "admin" }); + + await expect(appRouter.createCaller(ctx).salaires.list({ mois: 13 })).rejects.toThrow(); + expect(db.getSalairesMensuels).not.toHaveBeenCalled(); + }); +}); diff --git a/server/db.ts b/server/db.ts index b37fb79..bcc8df6 100644 --- a/server/db.ts +++ b/server/db.ts @@ -1,4 +1,4 @@ -import { and, asc, eq } from "drizzle-orm"; +import { and, asc, desc, eq } from "drizzle-orm"; import { drizzle } from "drizzle-orm/mysql2"; import { capexLignes, @@ -21,6 +21,8 @@ import { opexPostes, opexValidated, parametresApp, + salairesBulletins, + salairesLiasses, users, } from "../drizzle/schema"; import { isOpexEtablissementCode } from "../shared/opexValidation"; @@ -559,3 +561,151 @@ export async function setMasseSalarialeEvolution( }, }); } + +// ───────────────────────────────────────────────────────────────────────────── +// SALAIRES MENSUELS — Métadonnées de liasses et index de bulletins chiffrés +// ───────────────────────────────────────────────────────────────────────────── + +export type SalaireBulletinImport = { + matricule: string; + nom: string; + prenom: string; + poste: string; + brutMensuelCents: number; + brutAvecPrimesCents: number; + primeAstreinteCents: number; + explicationEcart?: string | null; + numeroPage?: number | null; +}; + +export type SalaireLiasseCreateInput = { + annee: number; + mois: number; + nomFichier: string; + stockageKey: string; + empreinteSha256: string; + tailleOctets: number; + ivBase64: string; + authTagBase64: string; + importePar: number; + statutExtraction: "ready" | "partiel" | "a_controler"; + erreurExtraction?: string | null; + bulletins: SalaireBulletinImport[]; +}; + +/** Retourne une liasse de période pour empêcher une importation mensuelle multiple. */ +export async function getSalaireLiasseByPeriod(annee: number, mois: number) { + const db = await getDb(); + if (!db) throw new Error("Database not available"); + const [liasse] = await db + .select() + .from(salairesLiasses) + .where(and(eq(salairesLiasses.annee, annee), eq(salairesLiasses.mois, mois))); + return liasse ?? null; +} + +/** Retourne une liasse par identifiant, sans jamais renvoyer de fichier déchiffré. */ +export async function getSalaireLiasseById(id: number) { + const db = await getDb(); + if (!db) throw new Error("Database not available"); + const [liasse] = await db.select().from(salairesLiasses).where(eq(salairesLiasses.id, id)); + return liasse ?? null; +} + +/** + * Persiste de façon atomique la référence chiffrée de la liasse et son index + * métier. Les octets du PDF restent exclusivement dans le stockage persistant. + */ +export async function createSalaireLiasse(input: SalaireLiasseCreateInput) { + const db = await getDb(); + if (!db) throw new Error("Database not available"); + + return db.transaction(async (tx) => { + const [existing] = await tx + .select({ id: salairesLiasses.id }) + .from(salairesLiasses) + .where(and(eq(salairesLiasses.annee, input.annee), eq(salairesLiasses.mois, input.mois))); + if (existing) throw new Error("Une liasse est déjà enregistrée pour cette période"); + + await tx.insert(salairesLiasses).values({ + annee: input.annee, + mois: input.mois, + nomFichier: input.nomFichier, + stockageKey: input.stockageKey, + empreinteSha256: input.empreinteSha256, + tailleOctets: input.tailleOctets, + ivBase64: input.ivBase64, + authTagBase64: input.authTagBase64, + importePar: input.importePar, + statutExtraction: input.statutExtraction, + erreurExtraction: input.erreurExtraction ?? null, + }); + + const [liasse] = await tx + .select({ id: salairesLiasses.id }) + .from(salairesLiasses) + .where(eq(salairesLiasses.stockageKey, input.stockageKey)); + if (!liasse) throw new Error("Liasse non retrouvée après enregistrement"); + + if (input.bulletins.length > 0) { + await tx.insert(salairesBulletins).values(input.bulletins.map((bulletin) => ({ + ...bulletin, + liasseId: liasse.id, + }))); + } + return { liasseId: liasse.id, bulletins: input.bulletins.length }; + }); +} + +/** + * Met à jour l'index métier d'une liasse déjà archivée sans modifier sa clé de + * stockage, son empreinte ou ses paramètres de chiffrement. + */ +export async function replaceSalaireLiasseIndex( + liasseId: number, + input: Pick, +) { + const db = await getDb(); + if (!db) throw new Error("Database not available"); + return db.transaction(async (tx) => { + await tx.delete(salairesBulletins).where(eq(salairesBulletins.liasseId, liasseId)); + await tx.update(salairesLiasses).set({ + statutExtraction: input.statutExtraction, + erreurExtraction: input.erreurExtraction ?? null, + }).where(eq(salairesLiasses.id, liasseId)); + if (input.bulletins.length > 0) { + await tx.insert(salairesBulletins).values(input.bulletins.map((bulletin) => ({ ...bulletin, liasseId }))); + } + return { bulletins: input.bulletins.length }; + }); +} + +/** + * Liste les liasses filtrées et les bulletins associés. La comparaison est + * réalisée dans le routeur à partir de ces montants normalisés en centimes. + */ +export async function getSalairesMensuels(annee?: number, mois?: number) { + const db = await getDb(); + if (!db) throw new Error("Database not available"); + const filter = annee && mois + ? and(eq(salairesLiasses.annee, annee), eq(salairesLiasses.mois, mois)) + : annee + ? eq(salairesLiasses.annee, annee) + : mois + ? eq(salairesLiasses.mois, mois) + : undefined; + + const liassesQuery = db.select().from(salairesLiasses); + const liasses = filter + ? await liassesQuery.where(filter).orderBy(desc(salairesLiasses.annee), desc(salairesLiasses.mois)) + : await liassesQuery.orderBy(desc(salairesLiasses.annee), desc(salairesLiasses.mois)); + + const bulletinsQuery = db + .select({ liasse: salairesLiasses, bulletin: salairesBulletins }) + .from(salairesBulletins) + .innerJoin(salairesLiasses, eq(salairesBulletins.liasseId, salairesLiasses.id)); + const rows = filter + ? await bulletinsQuery.where(filter).orderBy(asc(salairesBulletins.nom), asc(salairesBulletins.prenom)) + : await bulletinsQuery.orderBy(desc(salairesLiasses.annee), desc(salairesLiasses.mois), asc(salairesBulletins.nom)); + return { liasses, rows }; +} diff --git a/server/payrollCrypto.test.ts b/server/payrollCrypto.test.ts new file mode 100644 index 0000000..9972644 --- /dev/null +++ b/server/payrollCrypto.test.ts @@ -0,0 +1,37 @@ +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { decryptPayrollPdf, encryptPayrollPdf, getPayrollEncryptionStatus } from "./payrollCrypto"; + +const originalKey = process.env.PAYROLL_ENCRYPTION_KEY; + +describe.sequential("payrollCrypto", () => { + beforeEach(() => { + process.env.PAYROLL_ENCRYPTION_KEY = Buffer.alloc(32, 7).toString("base64"); + }); + + afterEach(() => { + if (originalKey === undefined) delete process.env.PAYROLL_ENCRYPTION_KEY; + else process.env.PAYROLL_ENCRYPTION_KEY = originalKey; + }); + + it("chiffre puis déchiffre un PDF sans changer son contenu", () => { + const original = Buffer.from("%PDF-1.7 contenu de test", "utf8"); + const encrypted = encryptPayrollPdf(original); + + expect(encrypted.ciphertext).not.toEqual(original); + expect(decryptPayrollPdf(encrypted)).toEqual(original); + expect(getPayrollEncryptionStatus()).toEqual({ ready: true, algorithm: "AES-256-GCM" }); + }); + + it("refuse un tag d'authentification altéré", () => { + const encrypted = encryptPayrollPdf(Buffer.from("%PDF-1.7 contenu de test", "utf8")); + const tamperedTag = Buffer.from(encrypted.authTagBase64, "base64"); + tamperedTag[0] = tamperedTag[0]! ^ 0xff; + + expect(() => decryptPayrollPdf({ ...encrypted, authTagBase64: tamperedTag.toString("base64") })).toThrow(); + }); + + it("ne révèle aucune erreur détaillée lorsque la clé est invalide", () => { + process.env.PAYROLL_ENCRYPTION_KEY = "invalide"; + expect(getPayrollEncryptionStatus()).toEqual({ ready: false, algorithm: "AES-256-GCM" }); + }); +}); diff --git a/server/payrollCrypto.ts b/server/payrollCrypto.ts new file mode 100644 index 0000000..75c8feb --- /dev/null +++ b/server/payrollCrypto.ts @@ -0,0 +1,62 @@ +import crypto from "node:crypto"; + +const ALGORITHM = "aes-256-gcm"; +const IV_BYTES = 12; +const AUTH_TAG_BYTES = 16; + +export type EncryptedPayrollFile = { + ciphertext: Buffer; + ivBase64: string; + authTagBase64: string; +}; + +/** + * Lit la clé dédiée aux bulletins. La clé ne transite jamais vers le client et + * doit être une valeur Base64 représentant précisément 32 octets AES-256. + */ +function getPayrollEncryptionKey(): Buffer { + const encodedKey = process.env.PAYROLL_ENCRYPTION_KEY?.trim(); + if (!encodedKey) throw new Error("Clé de chiffrement des bulletins absente"); + + const key = Buffer.from(encodedKey, "base64"); + if (key.length !== 32) { + throw new Error("La clé de chiffrement des bulletins doit contenir 32 octets"); + } + return key; +} + +/** Sondage sans divulgation de la clé, destiné aux contrôles administrateur. */ +export function getPayrollEncryptionStatus() { + try { + getPayrollEncryptionKey(); + return { ready: true as const, algorithm: "AES-256-GCM" }; + } catch { + return { ready: false as const, algorithm: "AES-256-GCM" }; + } +} + +/** Chiffre un PDF avant son envoi vers le stockage persistant. */ +export function encryptPayrollPdf(plaintext: Buffer): EncryptedPayrollFile { + const key = getPayrollEncryptionKey(); + const iv = crypto.randomBytes(IV_BYTES); + const cipher = crypto.createCipheriv(ALGORITHM, key, iv, { authTagLength: AUTH_TAG_BYTES }); + const ciphertext = Buffer.concat([cipher.update(plaintext), cipher.final()]); + return { + ciphertext, + ivBase64: iv.toString("base64"), + authTagBase64: cipher.getAuthTag().toString("base64"), + }; +} + +/** Déchiffre le document uniquement côté serveur, après contrôle des droits. */ +export function decryptPayrollPdf(encrypted: EncryptedPayrollFile): Buffer { + const key = getPayrollEncryptionKey(); + const iv = Buffer.from(encrypted.ivBase64, "base64"); + const authTag = Buffer.from(encrypted.authTagBase64, "base64"); + if (iv.length !== IV_BYTES || authTag.length !== AUTH_TAG_BYTES) { + throw new Error("Paramètres de chiffrement de bulletin invalides"); + } + const decipher = crypto.createDecipheriv(ALGORITHM, key, iv, { authTagLength: AUTH_TAG_BYTES }); + decipher.setAuthTag(authTag); + return Buffer.concat([decipher.update(encrypted.ciphertext), decipher.final()]); +} diff --git a/server/payrollParser.test.ts b/server/payrollParser.test.ts new file mode 100644 index 0000000..b2c58b5 --- /dev/null +++ b/server/payrollParser.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, it } from "vitest"; +import { extractPayrollBulletins } from "./payrollParser"; + +const SAMPLE_BULLETIN = ` +##BULLETIN##05-2026##009999##DUPONT##TEST## +Matricule : 009999 +Emploi : RESPONSABLE APPLICATIF Monsieur TEST DUPONT +Salaire de base (Forfait 218 jours) 3 650.00 +Prime d'astreinte 200.00 +Prime d'astreinte 200.00 +Prime d'activité 150.00 +Salaire brut 4 200.00 +N° SS : ne-pas-extraire +Iban / Rib : ne-pas-extraire +Net à payer avant impôt sur le revenu 2 000.00 +`; + +describe("extractPayrollBulletins", () => { + it("n'extrait que les champs métier autorisés et additionne les astreintes", () => { + const result = extractPayrollBulletins(SAMPLE_BULLETIN); + + expect(result).toMatchObject({ statutExtraction: "ready", erreurExtraction: null }); + expect(result.bulletins).toEqual([ + expect.objectContaining({ + matricule: "009999", + nom: "DUPONT", + prenom: "TEST", + poste: "RESPONSABLE APPLICATIF", + brutMensuelCents: 365000, + brutAvecPrimesCents: 420000, + primeAstreinteCents: 40000, + }), + ]); + expect(Object.keys(result.bulletins[0]!)).not.toContain("iban"); + expect(JSON.stringify(result)).not.toContain("ne-pas-extraire"); + }); + + it("signale une liasse non indexable sans prétendre perdre le PDF", () => { + expect(extractPayrollBulletins("contenu PDF non reconnu")).toEqual({ + bulletins: [], + statutExtraction: "a_controler", + erreurExtraction: "Aucun bulletin n’a pu être indexé automatiquement. Le PDF reste conservé et consultable.", + }); + }); + + it("indexe chaque salarié lorsque le texte PDF concatène les pages sans séparateur", () => { + const secondBulletin = SAMPLE_BULLETIN + .replace("##009999##DUPONT##TEST##", "##008888##MARTIN##EXEMPLE##") + .replaceAll("009999", "008888") + .replaceAll("TEST DUPONT", "EXEMPLE MARTIN"); + + const result = extractPayrollBulletins(`${SAMPLE_BULLETIN}\n${secondBulletin}`); + expect(result.bulletins).toHaveLength(2); + expect(result.bulletins.map((bulletin) => bulletin.matricule)).toEqual(["009999", "008888"]); + }); + + it("accepte un emploi suivi de la civilité par un seul espace, comme certains PDF extraits", () => { + const compacted = SAMPLE_BULLETIN.replace(" Monsieur", " Monsieur"); + + expect(extractPayrollBulletins(compacted).bulletins).toHaveLength(1); + }); + + it("lit un poste sur sa seule ligne lorsque la civilité est rendue à la ligne suivante", () => { + const separateCivilite = SAMPLE_BULLETIN.replace(" Monsieur TEST DUPONT", "\nMonsieur TEST DUPONT"); + + expect(extractPayrollBulletins(separateCivilite).bulletins).toHaveLength(1); + }); + + it("refuse une extraction d'emploi répartie sur plusieurs lignes pour ne jamais persister de donnée personnelle", () => { + const unsafeLayout = SAMPLE_BULLETIN.replace( + /Emploi\s*:[^\n]*/, + "Emploi :\nAdresse personnelle à ignorer\nRESPONSABLE APPLICATIF\nMonsieur TEST DUPONT", + ); + + expect(extractPayrollBulletins(unsafeLayout)).toMatchObject({ + bulletins: [], + statutExtraction: "a_controler", + }); + }); +}); diff --git a/server/payrollParser.ts b/server/payrollParser.ts new file mode 100644 index 0000000..5db9e05 --- /dev/null +++ b/server/payrollParser.ts @@ -0,0 +1,151 @@ +/** + * Extraction volontairement minimisée des liasses de paie. Aucune coordonnée, + * donnée bancaire, numéro de sécurité sociale ou rémunération nette n'est + * renvoyé ni persisté par ce module. + */ + +export type ParsedPayrollBulletin = { + matricule: string; + nom: string; + prenom: string; + poste: string; + brutMensuelCents: number; + brutAvecPrimesCents: number; + primeAstreinteCents: number; + explicationEcart: string | null; + numeroPage: number; +}; + +export type PayrollExtractionResult = { + bulletins: ParsedPayrollBulletin[]; + statutExtraction: "ready" | "partiel" | "a_controler"; + erreurExtraction: string | null; +}; + +const MONEY_RE = /(? Math.round(Number(raw.replace(/[ \u00a0]/g, "")) * 100)); +} + +function firstMatchingLine(lines: string[], prefix: string): string | null { + return lines.find((line) => line.trim().toLocaleLowerCase("fr-FR").startsWith(prefix)) ?? null; +} + +function pagesForHeaders(text: string): Array<{ content: string; page: number }> { + const pages = text.split("\f"); + const grouped: Array<{ content: string; page: number; matricule: string }> = []; + let current: { content: string; page: number; matricule: string } | null = null; + + pages.forEach((page, index) => { + HEADER_RE.lastIndex = 0; + const headers: Array<{ index: number; length: number; matricule: string }> = []; + let header: RegExpExecArray | null; + while ((header = HEADER_RE.exec(page)) !== null) { + headers.push({ index: header.index, length: header[0].length, matricule: header[1] }); + } + if (headers.length === 0 && current) { + current.content += `\n${page}`; + return; + } + if (headers.length === 0) return; + + headers.forEach((entry, headerIndex) => { + const next = headers[headerIndex + 1]; + const content = page.slice(entry.index, next?.index); + if (headerIndex === 0 && current?.matricule === entry.matricule) { + current.content += `\n${content}`; + } else { + current = { content, page: index + 1, matricule: entry.matricule }; + grouped.push(current); + } + }); + }); + + // Certains PDF ne contiennent pas de saut de page exploitable. On tente alors + // une extraction complète, qui sera marquée "à contrôler" sans en-tête fiable. + return grouped.length > 0 ? grouped.map(({ content, page }) => ({ content, page })) : [{ content: text, page: 1 }]; +} + +function getIdentity(text: string): { matricule: string; nom: string; prenom: string; poste: string } | null { + HEADER_RE.lastIndex = 0; + const header = HEADER_RE.exec(text); + const lines = text.split(/\r?\n/); + const matricule = /Matricule\s*:\s*(?:M|N|N°)?\s*(\d+)/.exec(text)?.[1] ?? header?.[1]; + const emploiLine = lines.find((line) => /Emploi\s*:/i.test(line)); + const poste = /Emploi\s*:\s*(.*)$/i.exec(emploiLine ?? "")?.[1] + ?.replace(/\s+(?:Monsieur|Madame)\b.*$/i, "") + .trim(); + if (!matricule || !poste) return null; + + if (header?.[2] && header[3]) { + return { + matricule, + nom: header[2].trim(), + prenom: header[3].trim(), + poste, + }; + } + + const displayName = /Emploi\s*:[\s\S]*?(?:Monsieur|Madame)\s+([A-ZÀ-ÖØ-Ý' -]+)/.exec(text)?.[1]?.trim(); + const names = displayName?.split(/\s+/).filter(Boolean) ?? []; + if (names.length < 2) return null; + return { matricule, prenom: names[0], nom: names.slice(1).join(" "), poste }; +} + +function parseOneBulletin(content: string, page: number): ParsedPayrollBulletin | null { + const lines = content.split(/\r?\n/).map((line) => line.trimEnd()); + const identity = getIdentity(content); + const baseAmounts = amountsIn(firstMatchingLine(lines, "salaire de base") ?? ""); + const grossAmounts = amountsIn(firstMatchingLine(lines, "salaire brut") ?? ""); + if (!identity || baseAmounts.length === 0 || grossAmounts.length === 0) return null; + + const primeAstreinteCents = lines + .filter((line) => line.trim().toLocaleLowerCase("fr-FR").startsWith("prime d'astreinte")) + .map((line) => amountsIn(line).at(-1) ?? 0) + .reduce((sum, amount) => sum + amount, 0); + + const brutMensuelCents = baseAmounts.at(-1)!; + const brutAvecPrimesCents = grossAmounts.at(-1)!; + const otherVariableCents = brutAvecPrimesCents - brutMensuelCents - primeAstreinteCents; + const parts: string[] = []; + if (primeAstreinteCents !== 0) parts.push("prime d’astreinte relevée"); + if (otherVariableCents !== 0) parts.push("autres éléments de rémunération inclus dans le brut"); + + return { + ...identity, + brutMensuelCents, + brutAvecPrimesCents, + primeAstreinteCents, + explicationEcart: parts.length ? parts.join(" ; ") : null, + numeroPage: page, + }; +} + +/** + * Transforme le texte d'une liasse PDF en index métier restreint. Toute absence + * de champ indispensable est signalée sans empêcher la conservation du PDF. + */ +export function extractPayrollBulletins(text: string): PayrollExtractionResult { + const candidates = pagesForHeaders(text); + const bulletins = candidates + .map(({ content, page }) => parseOneBulletin(content, page)) + .filter((bulletin): bulletin is ParsedPayrollBulletin => bulletin !== null); + + if (bulletins.length === 0) { + return { + bulletins: [], + statutExtraction: "a_controler", + erreurExtraction: "Aucun bulletin n’a pu être indexé automatiquement. Le PDF reste conservé et consultable.", + }; + } + if (bulletins.length !== candidates.length) { + return { + bulletins, + statutExtraction: "partiel", + erreurExtraction: "Une ou plusieurs fiches n’ont pas pu être indexées automatiquement. Le PDF reste consultable.", + }; + } + return { bulletins, statutExtraction: "ready", erreurExtraction: null }; +} diff --git a/server/payrollPdfText.ts b/server/payrollPdfText.ts new file mode 100644 index 0000000..915a78e --- /dev/null +++ b/server/payrollPdfText.ts @@ -0,0 +1,39 @@ +import pdfParse from "pdf-parse/lib/pdf-parse.js"; + +type PdfTextItem = { str?: string; transform?: number[] }; +type PdfPageData = { getTextContent: (options: { normalizeWhitespace: boolean; disableCombineTextItems: boolean }) => Promise<{ items: PdfTextItem[] }> }; + +/** + * Reconstruit les lignes en ordonnant les fragments de texte par coordonnées. + * Cette étape évite que les valeurs de colonnes distinctes (adresse, IBAN, etc.) + * soient concaténées dans l'intitulé de poste par l'extracteur PDF standard. + */ +export async function renderPayrollPage(pageData: unknown): Promise { + const pdfPage = pageData as PdfPageData; + const textContent = await pdfPage.getTextContent({ normalizeWhitespace: false, disableCombineTextItems: false }); + const fragments = textContent.items + .filter((item) => item.str && item.transform && item.transform.length >= 6) + .map((item) => ({ text: item.str!.trim(), x: item.transform![4]!, y: item.transform![5]! })) + .filter((item) => item.text.length > 0) + .sort((a, b) => Math.abs(b.y - a.y) > 0.75 ? b.y - a.y : a.x - b.x); + + const lines: Array<{ y: number; items: Array<{ text: string; x: number }> }> = []; + fragments.forEach((fragment) => { + const previous = lines[lines.length - 1]; + if (previous && Math.abs(previous.y - fragment.y) <= 0.75) { + previous.items.push({ text: fragment.text, x: fragment.x }); + } else { + lines.push({ y: fragment.y, items: [{ text: fragment.text, x: fragment.x }] }); + } + }); + + return `${lines + .map((line) => line.items.sort((a, b) => a.x - b.x).map((item) => item.text).join(" ")) + .join("\n")}\f`; +} + +/** Extrait le texte d'une liasse en préservant les limites de pages. */ +export async function extractPayrollPdfText(pdf: Buffer): Promise { + const parsed = await pdfParse(pdf, { pagerender: renderPayrollPage }); + return parsed.text; +} diff --git a/server/routers.ts b/server/routers.ts index c9c9ddd..6e4497c 100644 --- a/server/routers.ts +++ b/server/routers.ts @@ -8,6 +8,7 @@ import { sdk } from "./_core/sdk"; import { systemRouter } from "./_core/systemRouter"; import { adminProcedure, protectedProcedure, publicProcedure, router } from "./_core/trpc"; import { isOpexEtablissementCode, normalizeOpexEtablissementCode } from "../shared/opexValidation"; +import { getPayrollEncryptionStatus } from "./payrollCrypto"; const writeProcedure = protectedProcedure.use(({ ctx, next }) => { if (ctx.user.role === "readonly") { @@ -22,6 +23,35 @@ const opexEtablissementCodeSchema = z .transform(normalizeOpexEtablissementCode) .refine(isOpexEtablissementCode, { message: "Code établissement OPEX invalide" }); +const salaireFiltersSchema = z.object({ + annee: z.number().int().min(2020).max(2100).optional(), + mois: z.number().int().min(1).max(12).optional(), +}); + +function periodKey(annee: number, mois: number) { + return annee * 12 + (mois - 1); +} + +function describeMonthlySalaryDifference( + current: { brutMensuelCents: number; brutAvecPrimesCents: number; primeAstreinteCents: number }, + previous: { brutMensuelCents: number; brutAvecPrimesCents: number; primeAstreinteCents: number } | undefined, +) { + if (!previous) return "Aucun bulletin de ce salarié disponible au mois précédent."; + const difference = current.brutAvecPrimesCents - previous.brutAvecPrimesCents; + if (difference === 0) return "Brut stable par rapport au mois précédent."; + + const sign = (amount: number) => amount > 0 ? "hausse" : "baisse"; + const centsLabel = (amount: number) => `${(Math.abs(amount) / 100).toLocaleString("fr-FR", { minimumFractionDigits: 2, maximumFractionDigits: 2 })} €`; + const reasons: string[] = []; + const baseDifference = current.brutMensuelCents - previous.brutMensuelCents; + const astreinteDifference = current.primeAstreinteCents - previous.primeAstreinteCents; + const otherDifference = difference - baseDifference - astreinteDifference; + if (baseDifference !== 0) reasons.push(`${sign(baseDifference)} de la base (${centsLabel(baseDifference)})`); + if (astreinteDifference !== 0) reasons.push(`${sign(astreinteDifference)} des astreintes (${centsLabel(astreinteDifference)})`); + if (otherDifference !== 0) reasons.push(`${sign(otherDifference)} d'autres éléments inclus au brut (${centsLabel(otherDifference)})`); + return reasons.length > 0 ? reasons.join(" ; ") : "Variation du brut à contrôler."; +} + export const appRouter = router({ system: systemRouter, @@ -248,6 +278,88 @@ export const appRouter = router({ }), }), + /** Informations de disponibilité du stockage salarial, sans divulgation de secret. */ + salaires: router({ + getSecurityStatus: adminProcedure.query(() => getPayrollEncryptionStatus()), + + periods: adminProcedure.query(async () => { + const { liasses } = await db.getSalairesMensuels(); + return liasses.map((liasse) => ({ + id: liasse.id, + annee: liasse.annee, + mois: liasse.mois, + nomFichier: liasse.nomFichier, + tailleOctets: liasse.tailleOctets, + statutExtraction: liasse.statutExtraction, + erreurExtraction: liasse.erreurExtraction, + importeLe: liasse.importeLe, + })); + }), + + /** + * Renvoie exclusivement l'index métier des bulletins, jamais les références + * de stockage, empreintes ou paramètres cryptographiques. + */ + list: adminProcedure.input(salaireFiltersSchema).query(async ({ input }) => { + const { liasses, rows } = await db.getSalairesMensuels(); + const liassesByPeriod = new Map(liasses.map((liasse) => [periodKey(liasse.annee, liasse.mois), liasse])); + const rowsByLiasse = new Map>(); + rows.forEach(({ liasse, bulletin }) => { + const byEmployee = rowsByLiasse.get(liasse.id) ?? new Map(); + byEmployee.set(bulletin.matricule, bulletin); + rowsByLiasse.set(liasse.id, byEmployee); + }); + + const selectedLiasses = liasses.filter((liasse) => + (!input.annee || liasse.annee === input.annee) && (!input.mois || liasse.mois === input.mois), + ); + const selectedLiasseIds = new Set(selectedLiasses.map((liasse) => liasse.id)); + const bulletins = rows + .filter(({ liasse }) => selectedLiasseIds.has(liasse.id)) + .map(({ liasse, bulletin }) => { + const previousLiasse = liassesByPeriod.get(periodKey(liasse.annee, liasse.mois) - 1); + const previous = previousLiasse ? rowsByLiasse.get(previousLiasse.id)?.get(bulletin.matricule) : undefined; + const ecartBrutCents = previous ? bulletin.brutAvecPrimesCents - previous.brutAvecPrimesCents : null; + return { + liasseId: liasse.id, + annee: liasse.annee, + mois: liasse.mois, + matricule: bulletin.matricule, + nom: bulletin.nom, + prenom: bulletin.prenom, + poste: bulletin.poste, + brutMensuelCents: bulletin.brutMensuelCents, + brutAvecPrimesCents: bulletin.brutAvecPrimesCents, + primeAstreinteCents: bulletin.primeAstreinteCents, + brutPrecedentCents: previous?.brutAvecPrimesCents ?? null, + ecartBrutCents, + ecartBrutPourcentage: previous && previous.brutAvecPrimesCents !== 0 + ? Math.round((ecartBrutCents! / previous.brutAvecPrimesCents) * 10_000) / 100 + : null, + explication: describeMonthlySalaryDifference(bulletin, previous), + numeroPage: bulletin.numeroPage, + }; + }); + const totalBrutCents = bulletins.reduce((total, bulletin) => total + bulletin.brutAvecPrimesCents, 0); + const totalEcartCents = bulletins.reduce((total, bulletin) => total + (bulletin.ecartBrutCents ?? 0), 0); + return { + liasses: selectedLiasses.map((liasse) => ({ + id: liasse.id, + annee: liasse.annee, + mois: liasse.mois, + nomFichier: liasse.nomFichier, + tailleOctets: liasse.tailleOctets, + statutExtraction: liasse.statutExtraction, + erreurExtraction: liasse.erreurExtraction, + importeLe: liasse.importeLe, + pdfPath: `/api/salaires/liasses/${liasse.id}/pdf`, + })), + bulletins, + indicateurs: { totalBrutCents, totalEcartCents, nombreSalaries: bulletins.length }, + }; + }), + }), + // Les données de rémunération sont sensibles : aucune procédure de ce // sous-routeur n'est accessible aux profils standard ou lecture seule. masseSalariale: router({ diff --git a/server/salairesHttp.test.ts b/server/salairesHttp.test.ts new file mode 100644 index 0000000..30cb227 --- /dev/null +++ b/server/salairesHttp.test.ts @@ -0,0 +1,118 @@ +import express from "express"; +import request from "supertest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("./_core/sdk", () => ({ sdk: { authenticateRequest: vi.fn() } })); +vi.mock("./db", () => ({ + getSalaireLiasseByPeriod: vi.fn(), + createSalaireLiasse: vi.fn(), + getSalaireLiasseById: vi.fn(), + replaceSalaireLiasseIndex: vi.fn(), +})); +vi.mock("./storage", () => ({ storagePut: vi.fn(), storageGetBuffer: 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 pdfParse from "pdf-parse/lib/pdf-parse.js"; + +const EXTRACTED_TEXT = `##BULLETIN##05-2026##009999##DUPONT##TEST## +Matricule : 009999 +Emploi : RESPONSABLE APPLICATIF Monsieur TEST DUPONT +Salaire de base 3 650.00 +Salaire brut 3 650.00`; + +function makeApp() { + const app = express(); + registerSalairesHttpRoutes(app); + return app; +} + +describe("routes HTTP Salaires", () => { + beforeEach(() => { + vi.clearAllMocks(); + process.env.PAYROLL_ENCRYPTION_KEY = Buffer.alloc(32, 8).toString("base64"); + (getSalaireLiasseByPeriod as ReturnType).mockResolvedValue(null); + }); + + it("interdit l'upload au profil non administrateur", async () => { + (sdk.authenticateRequest as ReturnType).mockResolvedValue({ id: 2, role: "standard" }); + + await request(makeApp()) + .post("/api/salaires/liasses") + .field("annee", "2026") + .field("mois", "5") + .attach("file", Buffer.from("%PDF-1.7"), { filename: "mai.pdf", contentType: "application/pdf" }) + .expect(403); + + expect(createSalaireLiasse).not.toHaveBeenCalled(); + expect(storagePut).not.toHaveBeenCalled(); + }); + + it("chiffre et archive une liasse administrateur sans persister de donnée sensible", async () => { + (sdk.authenticateRequest as ReturnType).mockResolvedValue({ id: 1, role: "admin" }); + (pdfParse as ReturnType).mockResolvedValue({ text: EXTRACTED_TEXT }); + (storagePut as ReturnType).mockResolvedValue({ key: "salaires/2026/05/liasse_opaque.enc" }); + (createSalaireLiasse as ReturnType).mockResolvedValue({ liasseId: 12, bulletins: 1 }); + + const response = await request(makeApp()) + .post("/api/salaires/liasses") + .field("annee", "2026") + .field("mois", "5") + .attach("file", Buffer.from("%PDF-1.7 document de test"), { filename: "mai.pdf", contentType: "application/pdf" }) + .expect(201); + + expect(response.body).toMatchObject({ id: 12, bulletins: 1, statutExtraction: "ready" }); + expect(createSalaireLiasse).toHaveBeenCalledWith(expect.objectContaining({ + annee: 2026, + mois: 5, + importePar: 1, + stockageKey: "salaires/2026/05/liasse_opaque.enc", + bulletins: [expect.objectContaining({ matricule: "009999", brutMensuelCents: 365000 })], + })); + expect(JSON.stringify((createSalaireLiasse as ReturnType).mock.calls[0]?.[0])).not.toContain("N° SS"); + }); + + it("sert un PDF déchiffré uniquement à un administrateur et sans mise en cache", async () => { + (sdk.authenticateRequest as ReturnType).mockResolvedValue({ id: 1, role: "admin" }); + const plaintext = Buffer.from("%PDF-1.7 contenu de bulletin", "utf8"); + const encrypted = encryptPayrollPdf(plaintext); + (getSalaireLiasseById as ReturnType).mockResolvedValue({ id: 30001, stockageKey: "opaque", ivBase64: encrypted.ivBase64, authTagBase64: encrypted.authTagBase64, annee: 2026, mois: 5 }); + (storageGetBuffer as ReturnType).mockResolvedValue(encrypted.ciphertext); + + const response = await request(makeApp()).get("/api/salaires/liasses/30001/pdf").expect(200); + + expect(response.headers["cache-control"]).toContain("no-store"); + expect(response.headers["x-content-type-options"]).toBe("nosniff"); + expect(response.headers["content-type"]).toContain("application/pdf"); + expect(response.body).toEqual(plaintext); + }); + + it("interdit la consultation d'une liasse au profil lecture seule", async () => { + (sdk.authenticateRequest as ReturnType).mockResolvedValue({ id: 3, role: "readonly" }); + + await request(makeApp()).get("/api/salaires/liasses/12/pdf").expect(403); + expect(getSalaireLiasseById).not.toHaveBeenCalled(); + }); + + it("réindexe une liasse archivée sans téléverser ni remplacer son PDF", async () => { + const { replaceSalaireLiasseIndex } = await import("./db"); + (sdk.authenticateRequest as ReturnType).mockResolvedValue({ id: 1, role: "admin" }); + const encrypted = encryptPayrollPdf(Buffer.from("%PDF-1.7 contenu de bulletin", "utf8")); + (getSalaireLiasseById as ReturnType).mockResolvedValue({ id: 30001, stockageKey: "opaque", ivBase64: encrypted.ivBase64, authTagBase64: encrypted.authTagBase64, annee: 2026, mois: 5 }); + (storageGetBuffer as ReturnType).mockResolvedValue(encrypted.ciphertext); + (pdfParse as ReturnType).mockResolvedValue({ text: EXTRACTED_TEXT }); + (replaceSalaireLiasseIndex as ReturnType).mockResolvedValue({ bulletins: 1 }); + + await request(makeApp()).post("/api/salaires/liasses/30001/reindex").expect(200); + + expect(replaceSalaireLiasseIndex).toHaveBeenCalledWith(30001, expect.objectContaining({ + statutExtraction: "ready", + bulletins: [expect.objectContaining({ matricule: "009999" })], + })); + expect(storagePut).not.toHaveBeenCalled(); + }); +}); diff --git a/server/salairesHttp.ts b/server/salairesHttp.ts new file mode 100644 index 0000000..eba350c --- /dev/null +++ b/server/salairesHttp.ts @@ -0,0 +1,185 @@ +import crypto from "node:crypto"; +import type { Express, Request, Response } from "express"; +import multer from "multer"; +import { sdk } from "./_core/sdk"; +import { createSalaireLiasse, getSalaireLiasseById, getSalaireLiasseByPeriod, replaceSalaireLiasseIndex } from "./db"; +import { decryptPayrollPdf, encryptPayrollPdf } from "./payrollCrypto"; +import { extractPayrollBulletins, type PayrollExtractionResult } from "./payrollParser"; +import { extractPayrollPdfText } from "./payrollPdfText"; +import { storageGetBuffer, storagePut } from "./storage"; + +const MAX_PDF_BYTES = 25 * 1024 * 1024; +const upload = multer({ + storage: multer.memoryStorage(), + limits: { fileSize: MAX_PDF_BYTES, files: 1 }, +}); + +function parsePeriod(value: unknown, min: number, max: number): number | null { + if (typeof value !== "string" || !/^\d+$/.test(value)) return null; + const parsed = Number(value); + return Number.isSafeInteger(parsed) && parsed >= min && parsed <= max ? parsed : null; +} + +function isPdf(buffer: Buffer): boolean { + return buffer.subarray(0, 5).toString("ascii") === "%PDF-"; +} + +function safeUploadFilename(value: string | undefined): string { + const normalized = (value ?? "liasse.pdf").replace(/[\\/\0]/g, "").trim(); + return normalized.slice(0, 255) || "liasse.pdf"; +} + +async function getAdminUser(req: Request, res: Response) { + let user; + try { + user = await sdk.authenticateRequest(req); + } catch { + res.status(401).json({ error: "Authentification requise." }); + return null; + } + if (user.role !== "admin") { + res.status(403).json({ error: "Accès réservé aux administrateurs." }); + return null; + } + return user; +} + +/** + * Routes REST limitées aux transferts binaires. Toutes les données métier sont + * lues via tRPC ; aucune URL de stockage ne quitte ce module serveur. + */ +export function registerSalairesHttpRoutes(app: Express) { + app.post("/api/salaires/liasses", upload.single("file"), async (req, res) => { + const user = await getAdminUser(req, res); + if (!user) return; + + const annee = parsePeriod(req.body?.annee, 2020, 2100); + const mois = parsePeriod(req.body?.mois, 1, 12); + const file = req.file; + if (!annee || !mois || !file || !isPdf(file.buffer)) { + res.status(400).json({ error: "Transmettez une période valide et un fichier PDF conforme." }); + return; + } + + const alreadyImported = await getSalaireLiasseByPeriod(annee, mois); + if (alreadyImported) { + res.status(409).json({ error: "Une liasse est déjà archivée pour cette période. Elle ne peut pas être écrasée." }); + return; + } + + // pdf-parse est exécuté en mémoire : le texte des bulletins n'est ni journalisé + // ni envoyé au navigateur. Un échec d'analyse laisse une archive consultable. + let extraction: PayrollExtractionResult = { + bulletins: [], + statutExtraction: "a_controler" as const, + erreurExtraction: "La liasse a été archivée, mais son index doit être contrôlé manuellement.", + }; + try { + extraction = extractPayrollBulletins(await extractPayrollPdfText(file.buffer)); + } catch { + // Le message reste générique afin de ne pas divulguer de détail du contenu PDF. + } + + try { + const encrypted = encryptPayrollPdf(file.buffer); + const archive = await storagePut( + `salaires/${annee}/${String(mois).padStart(2, "0")}/liasse.pdf.enc`, + encrypted.ciphertext, + "application/octet-stream", + ); + const persisted = await createSalaireLiasse({ + annee, + mois, + nomFichier: safeUploadFilename(file.originalname), + stockageKey: archive.key, + empreinteSha256: crypto.createHash("sha256").update(file.buffer).digest("hex"), + tailleOctets: file.buffer.length, + ivBase64: encrypted.ivBase64, + authTagBase64: encrypted.authTagBase64, + importePar: user.id, + statutExtraction: extraction.statutExtraction, + erreurExtraction: extraction.erreurExtraction, + bulletins: extraction.bulletins, + }); + + res.status(201).json({ + id: persisted.liasseId, + bulletins: persisted.bulletins, + statutExtraction: extraction.statutExtraction, + avertissement: extraction.erreurExtraction, + }); + } catch (error) { + console.error("[Salaires] Échec de l'archivage d'une liasse", error instanceof Error ? error.message : "erreur inconnue"); + res.status(500).json({ error: "L'archivage sécurisé de la liasse a échoué. Aucun enregistrement n'a été créé." }); + } + }); + + app.post("/api/salaires/liasses/:id/reindex", async (req, res) => { + const user = await getAdminUser(req, res); + if (!user) return; + const id = parsePeriod(req.params.id, 1, Number.MAX_SAFE_INTEGER); + if (!id) { + res.status(400).json({ error: "Référence de liasse invalide." }); + return; + } + const liasse = await getSalaireLiasseById(id); + if (!liasse) { + res.status(404).json({ error: "Liasse introuvable." }); + return; + } + try { + const ciphertext = await storageGetBuffer(liasse.stockageKey); + const pdf = decryptPayrollPdf({ ciphertext, ivBase64: liasse.ivBase64, authTagBase64: liasse.authTagBase64 }); + if (!isPdf(pdf)) throw new Error("Invalid decrypted PDF signature"); + const extraction = extractPayrollBulletins(await extractPayrollPdfText(pdf)); + if (extraction.bulletins.length === 0) { + res.status(422).json({ error: "Aucun bulletin n'a pu être indexé. L'archive et l'index existant sont conservés." }); + return; + } + const result = await replaceSalaireLiasseIndex(id, extraction); + res.json({ bulletins: result.bulletins, statutExtraction: extraction.statutExtraction, avertissement: extraction.erreurExtraction }); + } catch (error) { + console.error("[Salaires] Échec de la réindexation d'une liasse", error instanceof Error ? error.message : "erreur inconnue"); + res.status(503).json({ error: "La liasse ne peut pas être réindexée pour le moment. Son archive reste inchangée." }); + } + }); + + app.get("/api/salaires/liasses/:id/pdf", async (req, res) => { + const user = await getAdminUser(req, res); + if (!user) return; + + const id = parsePeriod(req.params.id, 1, Number.MAX_SAFE_INTEGER); + if (!id) { + res.status(400).json({ error: "Référence de liasse invalide." }); + return; + } + const liasse = await getSalaireLiasseById(id); + if (!liasse) { + res.status(404).json({ error: "Liasse introuvable." }); + return; + } + + try { + const ciphertext = await storageGetBuffer(liasse.stockageKey); + const pdf = decryptPayrollPdf({ + ciphertext, + ivBase64: liasse.ivBase64, + authTagBase64: liasse.authTagBase64, + }); + if (!isPdf(pdf)) throw new Error("Invalid decrypted PDF signature"); + + res.set({ + "Content-Type": "application/pdf", + "Content-Length": String(pdf.length), + "Content-Disposition": `inline; filename="bulletins-${liasse.annee}-${String(liasse.mois).padStart(2, "0")}.pdf"`, + "Cache-Control": "no-store, private", + Pragma: "no-cache", + "X-Content-Type-Options": "nosniff", + }); + res.send(pdf); + } catch (error) { + console.error("[Salaires] Échec de la consultation d'une liasse", error instanceof Error ? error.message : "erreur inconnue"); + res.status(503).json({ error: "Le document ne peut pas être consulté pour le moment." }); + } + }); +} diff --git a/server/storage.ts b/server/storage.ts index 1d88470..0728ac5 100644 --- a/server/storage.ts +++ b/server/storage.ts @@ -95,3 +95,15 @@ export async function storageGetSignedUrl(relKey: string): Promise { const { url } = (await resp.json()) as { url: string }; return url; } + +/** + * Télécharge un objet uniquement depuis le serveur. Cette primitive est utilisée + * par la consultation de bulletins afin de ne jamais communiquer de lien de + * stockage ni de document chiffré au navigateur. + */ +export async function storageGetBuffer(relKey: string): Promise { + const signedUrl = await storageGetSignedUrl(relKey); + const response = await fetch(signedUrl, { redirect: "follow" }); + if (!response.ok) throw new Error(`Storage download failed (${response.status})`); + return Buffer.from(await response.arrayBuffer()); +} diff --git a/server/types/pdf-parse-internal.d.ts b/server/types/pdf-parse-internal.d.ts new file mode 100644 index 0000000..3a8d278 --- /dev/null +++ b/server/types/pdf-parse-internal.d.ts @@ -0,0 +1,6 @@ +declare module "pdf-parse/lib/pdf-parse.js" { + type PdfParseResult = { text: string }; + type PdfParseOptions = { pagerender?: (pageData: unknown) => Promise }; + const pdfParse: (data: Buffer, options?: PdfParseOptions) => Promise; + export default pdfParse; +} diff --git a/todo.md b/todo.md index e2c8700..910073e 100644 --- a/todo.md +++ b/todo.md @@ -140,3 +140,57 @@ - [x] Remplacer les logos de stockage indisponibles par des wordmarks sans dépendance externe - [x] Publier le correctif de wordmarks sur Gitea recette - [x] Vérifier visuellement la page de connexion de recette après publication + +## Export Excel Masse salariale +- [x] Isoler, dans les bulletins, les seules primes d'astreinte à déduire du brut +- [x] Ajouter une colonne Astreintes à déduire modifiable pour chaque année +- [x] Calculer le brut hors primes par formule : brut annuel avec primes moins astreintes à déduire +- [x] Générer un tableau Excel 2025–2027 au format de la fenêtre Masse salariale +- [x] Vérifier les formules, les montants et la mise en forme du fichier Excel + +## Mise à jour des astreintes selon le planning +- [x] Identifier les semaines d'astreinte 2025 et 2026 du planning pour les six salariés ciblés +- [x] Tracer explicitement les segments partiels à cheval entre exercices, exclus du décompte de semaine complète +- [ ] 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] Relier le tableau principal aux totaux annuels d'astreinte des onglets dédiés +- [x] Vérifier les décomptes chiffrés et le fichier Excel final actualisé + +## Finalisation prioritaire — Astreintes +- [x] Recalculer et livrer le fichier Excel selon la règle lundi-lundi complète +- [x] Utiliser exclusivement les semaines planifiées à 200 € comme source des astreintes 2025 et 2026 +- [x] Supprimer les références au cumul de période des onglets et de la documentation + +## Livraison prioritaire du classeur +- [x] Transmettre immédiatement la dernière version du fichier Excel avec les astreintes actualisées + +## Fenêtre Salaires mensuelle +- [x] Confirmer les listes de données à afficher après téléversement d'une liasse mensuelle +- [x] Créer le stockage sécurisé des fichiers de paie et de leurs métadonnées +- [x] Créer l'import administrateur, l'extraction contrôlée et les procédures tRPC +- [x] Ajouter la fenêtre Salaires dans le menu Santinova et afficher les listes mensuelles +- [x] Tester les droits, le téléversement, l'extraction et les états d'interface + +## Détails attendus — Salaires mensuels +- [x] Conserver chaque liasse PDF en stockage sécurisé et proposer sa consultation administrateur +- [x] Afficher salarié et salaire brut pour chaque mois, filtrables par année et mois +- [x] Comparer le brut d'un salarié au mois précédent avec explication basée sur les rubriques extraites + +## Sécurité des bulletins de salaire +- [x] Chiffrer les fichiers PDF avant leur stockage dédié et ne conserver en base que les métadonnées et références +- [x] Contrôler que la consultation des PDFs chiffrés est réservée aux administrateurs +- [ ] 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 + +## Exécution en arrière-plan +- [ ] 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