From e32d540bc909dc70559599e40e08f9893d085d96 Mon Sep 17 00:00:00 2001 From: Manus Date: Mon, 8 Jun 2026 09:24:05 -0400 Subject: [PATCH] =?UTF-8?q?Checkpoint:=20Ajout=20de=20l'onglet=20"Param?= =?UTF-8?q?=C3=A9trage"=20dans=20la=20page=20Ventilation=20FreePro=20:=20-?= =?UTF-8?q?=20Table=20DB=20freeproSettings=20(URL=20portail,=20credentials?= =?UTF-8?q?,=20fr=C3=A9quence,=20date=20ant=C3=A9riorit=C3=A9,=20statut=20?= =?UTF-8?q?derni=C3=A8re=20r=C3=A9cup=C3=A9ration)=20-=20Service=20freepro?= =?UTF-8?q?AutoImport.ts=20:=20connexion=20HTTP=20au=20portail=20FreePro,?= =?UTF-8?q?=20t=C3=A9l=C3=A9chargement=20CSV,=20pipeline=20d'import=20-=20?= =?UTF-8?q?Proc=C3=A9dures=20tRPC=20:=20getSettings,=20saveSettings,=20tes?= =?UTF-8?q?tConnection,=20forceImport=20-=20Job=20p=C3=A9riodique=20en=20m?= =?UTF-8?q?=C3=A9moire=20(daily/weekly/monthly)=20via=20setInterval=20-=20?= =?UTF-8?q?Frontend=20:=20wrapper=20Tabs=20(onglet=201=20=3D=20Import=20&?= =?UTF-8?q?=20Historique,=20onglet=202=20=3D=20Param=C3=A9trage)=20-=20Ong?= =?UTF-8?q?let=20Param=C3=A9trage=20:=20credentials,=20fr=C3=A9quence,=20d?= =?UTF-8?q?ate=20ant=C3=A9riorit=C3=A9,=20bouton=20"Forcer=20r=C3=A9cup?= =?UTF-8?q?=C3=A9ration",=20statut=20-=20Tests=20unitaires=20:=208=20tests?= =?UTF-8?q?=20pass=C3=A9s?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- client/src/pages/VentilationFreePro.tsx | 994 +++++++---- drizzle/0029_unknown_spot.sql | 17 + drizzle/meta/0029_snapshot.json | 2112 +++++++++++++++++++++++ drizzle/meta/_journal.json | 7 + drizzle/schema.ts | 31 + server/db.ts | 72 + server/freeproAutoImport.ts | 491 ++++++ server/freeproSettings.test.ts | 101 ++ server/routers.ts | 75 + todo.md | 10 + 10 files changed, 3615 insertions(+), 295 deletions(-) create mode 100644 drizzle/0029_unknown_spot.sql create mode 100644 drizzle/meta/0029_snapshot.json create mode 100644 server/freeproAutoImport.ts create mode 100644 server/freeproSettings.test.ts diff --git a/client/src/pages/VentilationFreePro.tsx b/client/src/pages/VentilationFreePro.tsx index 0739581..a98fe32 100644 --- a/client/src/pages/VentilationFreePro.tsx +++ b/client/src/pages/VentilationFreePro.tsx @@ -1,9 +1,10 @@ -import { useState, useRef, useCallback } from "react"; +import { useState, useRef, useCallback, useEffect } from "react"; import { trpc } from "@/lib/trpc"; import DashboardLayout from "@/components/DashboardLayout"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Badge } from "@/components/ui/badge"; +import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs"; import { Table, TableBody, @@ -35,8 +36,16 @@ import { BarChart3, Euro, Share2, + Settings, + RefreshCw, + CheckCircle2, + XCircle, + Clock, + Wifi, + WifiOff, + Eye, + EyeOff, } from "lucide-react"; -// PDF généré côté serveur via trpc.freepro.generatePdf // ── Types ────────────────────────────────────────────────────────────────── @@ -82,7 +91,382 @@ function typeBadgeColor(type: string): string { return "bg-orange-100 text-orange-800 border-orange-200"; } -// PDF généré côté serveur — pas de jsPDF côté client +// ── Onglet Paramétrage ───────────────────────────────────────────────────── + +function ParametrageTab() { + const utils = trpc.useUtils(); + + // Champs du formulaire + const [portalUrl, setPortalUrl] = useState("https://pro.free.fr"); + const [loginEmail, setLoginEmail] = useState(""); + const [loginPassword, setLoginPassword] = useState(""); + const [showPassword, setShowPassword] = useState(false); + const [frequency, setFrequency] = useState<"manual" | "daily" | "weekly" | "monthly">("manual"); + const [maxAnteriority, setMaxAnteriority] = useState(""); // date string YYYY-MM-DD + const [autoEnabled, setAutoEnabled] = useState(false); + + // État UI + const [isTesting, setIsTesting] = useState(false); + const [isForcing, setIsForcing] = useState(false); + const [lastForceResult, setLastForceResult] = useState<{ success: boolean; message: string } | null>(null); + + // Query paramètres + const { data: settings, isLoading: loadingSettings } = trpc.freepro.getSettings.useQuery(); + + // Hydratation du formulaire depuis la DB + useEffect(() => { + if (!settings) return; + if (settings.portalUrl) setPortalUrl(settings.portalUrl); + if (settings.loginEmail) setLoginEmail(settings.loginEmail); + if (settings.loginPassword) setLoginPassword(settings.loginPassword); // masqué côté serveur + setFrequency((settings.frequency as any) ?? "manual"); + if (settings.maxAnteriority) { + const d = new Date(settings.maxAnteriority * 1000); + setMaxAnteriority(d.toISOString().split("T")[0]); + } + setAutoEnabled(settings.autoEnabled === 1); + }, [settings]); + + // Mutations + const saveSettingsMutation = trpc.freepro.saveSettings.useMutation({ + onSuccess: () => { + utils.freepro.getSettings.invalidate(); + toast.success("Paramètres FreePro sauvegardés"); + }, + onError: (err) => toast.error(`Erreur : ${err.message}`), + }); + + const testConnectionMutation = trpc.freepro.testConnection.useMutation({ + onSuccess: (data) => { + setIsTesting(false); + if (data.success) { + toast.success(data.message); + } else { + toast.error(data.message); + } + }, + onError: (err) => { + setIsTesting(false); + toast.error(`Erreur de connexion : ${err.message}`); + }, + }); + + const forceImportMutation = trpc.freepro.forceImport.useMutation({ + onSuccess: (data) => { + setIsForcing(false); + setLastForceResult({ success: data.success, message: data.message }); + if (data.success) { + toast.success(data.message); + utils.freepro.list.invalidate(); + utils.freepro.getSettings.invalidate(); + } else { + toast.error(data.message); + } + }, + onError: (err) => { + setIsForcing(false); + setLastForceResult({ success: false, message: err.message }); + toast.error(`Erreur : ${err.message}`); + }, + }); + + const handleSave = () => { + const anteriorityTs = maxAnteriority + ? Math.floor(new Date(maxAnteriority).getTime() / 1000) + : null; + + saveSettingsMutation.mutate({ + portalUrl, + loginEmail, + loginPassword: loginPassword !== "••••••••" ? loginPassword : undefined, + frequency, + maxAnteriority: anteriorityTs, + autoEnabled: autoEnabled ? 1 : 0, + }); + }; + + const handleTestConnection = () => { + if (!loginEmail || !loginPassword || loginPassword === "••••••••") { + toast.error("Veuillez saisir l'email et le mot de passe avant de tester"); + return; + } + setIsTesting(true); + testConnectionMutation.mutate({ email: loginEmail, password: loginPassword }); + }; + + const handleForceImport = () => { + setIsForcing(true); + setLastForceResult(null); + forceImportMutation.mutate(); + }; + + if (loadingSettings) { + return ( +
+
+
+ ); + } + + return ( +
+ {/* Connexion au portail FreePro */} + + + + + Connexion au portail FreePro + +

+ Configurez les identifiants pour la récupération automatique des factures CSV depuis{" "} + + pro.free.fr + +

+
+ + {/* URL portail */} +
+ +
+ setPortalUrl(e.target.value)} + className="w-full border rounded px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500" + placeholder="https://pro.free.fr" + /> +
+
+ + {/* Email */} +
+ +
+ setLoginEmail(e.target.value)} + className="w-full border rounded px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500" + placeholder="votre@email.com" + /> +
+
+ + {/* Mot de passe */} +
+ +
+ setLoginPassword(e.target.value)} + className="w-full border rounded px-3 py-2 pr-10 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500" + placeholder="Mot de passe FreePro" + /> + +
+
+ + {/* Bouton tester connexion */} +
+ +
+
+
+ + {/* Paramètres de récupération automatique */} + + + + + Récupération automatique + + + + {/* Activation */} +
+
+

Récupération automatique

+

+ Si activée, les nouvelles factures seront importées automatiquement selon la fréquence configurée +

+
+ +
+ + {/* Fréquence */} +
+ +
+ +
+
+ + {/* Date d'antériorité */} +
+
+ +

+ Les factures antérieures à cette date ne seront pas récupérées +

+
+
+ setMaxAnteriority(e.target.value)} + className="w-full border rounded px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500" + /> + {maxAnteriority && ( +

+ Seules les factures à partir du{" "} + {new Date(maxAnteriority).toLocaleDateString("fr-FR")} seront récupérées +

+ )} + +
+
+
+
+ + {/* Boutons d'action */} +
+ + + +
+ + {/* Résultat de la dernière récupération forcée */} + {lastForceResult && ( + + +
+ {lastForceResult.success ? ( + + ) : ( + + )} +
+

+ {lastForceResult.success ? "Récupération terminée" : "Échec de la récupération"} +

+

{lastForceResult.message}

+
+
+
+
+ )} + + {/* Statut de la dernière récupération automatique */} + {settings && (settings.lastStatus || settings.lastSuccessAt) && ( + + + + + Statut de la dernière récupération automatique + + + + {settings.lastSuccessAt && ( +
+ + Dernière réussite : + + {new Date(settings.lastSuccessAt).toLocaleString("fr-FR")} + +
+ )} + {settings.lastImportCount !== null && settings.lastImportCount !== undefined && ( +
+ + Dernière récupération : + {settings.lastImportCount} facture(s) importée(s) +
+ )} + {settings.lastStatus && ( +
+ Message : + {settings.lastStatus} +
+ )} +
+
+ )} +
+ ); +} // ── Composant principal ──────────────────────────────────────────────────── @@ -137,7 +521,6 @@ function VentilationFreeProContent() { const generatePdfMutation = trpc.freepro.generatePdf.useMutation({ onSuccess: (data) => { setIsExportingPdf(false); - // Télécharger le PDF depuis le base64 const byteChars = atob(data.base64); const byteArr = new Uint8Array(byteChars.length); for (let i = 0; i < byteChars.length; i++) byteArr[i] = byteChars.charCodeAt(i); @@ -200,325 +583,346 @@ function VentilationFreeProContent() { [handleFile] ); - // ── Rendu liste ────────────────────────────────────────────────────────── - if (view === "list") { + // ── Vue détail (pas d'onglets) ──────────────────────────────────────────── + if (view === "detail") { + const imp = detail?.import as ImportRecord | undefined; + const lines = (detail?.lines ?? []) as VentilationLine[]; + const totalCentimes = lines.reduce((s, l) => s + l.montantCentimes, 0); + return (
- {/* En-tête */} + {/* En-tête détail */}
- -
-

Ventilation FreePro

-

Import et ventilation des factures Free Pro par structure

+ +
+
+ +

+ Ventilation FreePro — {imp?.moisLabel ?? "…"} +

+ {imp && ( +
+ + +
+ )}
- {/* Zone d'import */} - - - - - Importer une facture FreePro - - - - {/* Sélecteur de mois */} -
- - setMoisLabel(e.target.value)} - placeholder="MM/AAAA" - className="border rounded px-3 py-1.5 text-sm w-32 font-mono focus:outline-none focus:ring-2 focus:ring-blue-500" - /> - Format : MM/AAAA (ex : 06/2025) + {loadingDetail ? ( +
+
+
+ ) : imp ? ( + <> + {/* Métadonnées */} +
+ + +

Référence pièce

+

{imp.refPiece ?? "—"}

+
+
+ + +

Mois

+

01/{imp.moisLabel}

+
+
+ + +

Lignes traitées

+

{imp.nbLignes}

+
+
+ + +

+ Total TTC +

+

{formatTotalTtc(imp.totalTtc)}

+
+
- {/* Zone de dépôt */} -
{ e.preventDefault(); setIsDragging(true); }} - onDragLeave={() => setIsDragging(false)} - onDrop={handleDrop} - onClick={() => fileInputRef.current?.click()} - > - -

Glissez votre fichier FreePro ici

-

ou cliquez pour sélectionner

-

Formats acceptés : .xlsx, .xls, .csv

- { const f = e.target.files?.[0]; if (f) handleFile(f); }} - /> -
- - {importMutation.isPending && ( -
-
- Traitement en cours… -
- )} - - - - {/* Historique */} - - - - - Historique des imports ({imports.length}) - - - - {loadingList ? ( -
-
-
- ) : imports.length === 0 ? ( -
- -

Aucun import enregistré

-
- ) : ( - - - - Mois - Réf. pièce - Fichier - Lignes - Total TTC - Importé le - Actions - - - - {(imports as ImportRecord[]).map((imp) => ( - { setSelectedImportId(imp.id); setView("detail"); }} - > - - - {imp.moisLabel} - - - {imp.refPiece ?? "—"} - {imp.fileName} - - - - {imp.nbLignes} - - - - {formatTotalTtc(imp.totalTtc)} - - - {new Date(imp.createdAt).toLocaleDateString("fr-FR")} - - -
e.stopPropagation()}> - - -
-
+ {/* Tableau de ventilation */} + + + Ventilation par structure et type + + +
+ + + Structure + Type + Montant TTC - ))} - -
- )} - - - - {/* Dialog de confirmation de suppression */} - setDeleteId(null)}> - - - Supprimer cet import ? - - Cette action est irréversible. Toutes les lignes de ventilation associées seront supprimées. - - - - Annuler - { if (deleteId) deleteMutation.mutate({ id: deleteId }); setDeleteId(null); }} - > - Supprimer - - - - + + + {lines.map((line) => ( + + + {line.structure ?? (vide)} + + + + {line.type} + + + + {formatMontant(line.montantCentimes)} + + + ))} + + + + Total général + + {formatMontant(totalCentimes)} + + + + + + + + ) : ( +
Import introuvable.
+ )}
); } - // ── Rendu détail ────────────────────────────────────────────────────────── - - const imp = detail?.import as ImportRecord | undefined; - const lines = (detail?.lines ?? []) as VentilationLine[]; - const totalCentimes = lines.reduce((s, l) => s + l.montantCentimes, 0); - + // ── Vue liste avec onglets ───────────────────────────────────────────────── return (
- {/* En-tête détail */} + {/* En-tête */}
- -
-
- -

- Ventilation FreePro — {imp?.moisLabel ?? "…"} -

+ +
+

Ventilation FreePro

+

Import et ventilation des factures Free Pro par structure

- {imp && ( -
- - -
- )}
- {loadingDetail ? ( -
-
-
- ) : imp ? ( - <> - {/* Métadonnées */} -
- - -

Référence pièce

-

{imp.refPiece ?? "—"}

-
-
- - -

Mois

-

01/{imp.moisLabel}

-
-
- - -

Lignes traitées

-

{imp.nbLignes}

-
-
- - -

- Total TTC -

-

{formatTotalTtc(imp.totalTtc)}

-
-
-
+ {/* Onglets */} + + + + + Import & Historique + + + + Paramétrage + + - {/* Tableau de ventilation */} + {/* ── Onglet 1 : Import manuel + Historique ── */} + + {/* Zone d'import */} - - Ventilation par structure et type + + + + Importer une facture FreePro + - - - - - Structure - Type - Montant TTC - - - - {lines.map((line) => ( - - - {line.structure ?? (vide)} - - - - {line.type} - - - - {formatMontant(line.montantCentimes)} - - - ))} - - - - - - - -
Total général - {formatMontant(totalCentimes)} -
+ + {/* Sélecteur de mois */} +
+ + setMoisLabel(e.target.value)} + placeholder="MM/AAAA" + className="border rounded px-3 py-1.5 text-sm w-32 font-mono focus:outline-none focus:ring-2 focus:ring-blue-500" + /> + Format : MM/AAAA (ex : 06/2025) +
+ + {/* Zone de dépôt */} +
{ e.preventDefault(); setIsDragging(true); }} + onDragLeave={() => setIsDragging(false)} + onDrop={handleDrop} + onClick={() => fileInputRef.current?.click()} + > + +

Glissez votre fichier FreePro ici

+

ou cliquez pour sélectionner

+

Formats acceptés : .xlsx, .xls, .csv

+ { const f = e.target.files?.[0]; if (f) handleFile(f); }} + /> +
+ + {importMutation.isPending && ( +
+
+ Traitement en cours… +
+ )} - - ) : ( -
Import introuvable.
- )} + + {/* Historique */} + + + + + Historique des imports ({imports.length}) + + + + {loadingList ? ( +
+
+
+ ) : imports.length === 0 ? ( +
+ +

Aucun import enregistré

+
+ ) : ( + + + + Mois + Réf. pièce + Fichier + Lignes + Total TTC + Importé le + Actions + + + + {(imports as ImportRecord[]).map((imp) => ( + { setSelectedImportId(imp.id); setView("detail"); }} + > + + + {imp.moisLabel} + + + {imp.refPiece ?? "—"} + {imp.fileName} + + + + {imp.nbLignes} + + + + {formatTotalTtc(imp.totalTtc)} + + + {new Date(imp.createdAt).toLocaleDateString("fr-FR")} + + +
e.stopPropagation()}> + + +
+
+
+ ))} +
+
+ )} + + + + + {/* ── Onglet 2 : Paramétrage ── */} + + + + + + {/* Dialog de confirmation de suppression */} + setDeleteId(null)}> + + + Supprimer cet import ? + + Cette action est irréversible. Toutes les lignes de ventilation associées seront supprimées. + + + + Annuler + { if (deleteId) deleteMutation.mutate({ id: deleteId }); setDeleteId(null); }} + > + Supprimer + + + +
); } diff --git a/drizzle/0029_unknown_spot.sql b/drizzle/0029_unknown_spot.sql new file mode 100644 index 0000000..5d387bd --- /dev/null +++ b/drizzle/0029_unknown_spot.sql @@ -0,0 +1,17 @@ +CREATE TABLE `freeproSettings` ( + `id` int AUTO_INCREMENT NOT NULL, + `userId` int NOT NULL, + `portalUrl` varchar(255) NOT NULL DEFAULT 'https://pro.free.fr', + `loginEmail` varchar(320), + `loginPassword` text, + `frequency` enum('manual','daily','weekly','monthly') NOT NULL DEFAULT 'manual', + `maxAnteriority` int, + `autoEnabled` int NOT NULL DEFAULT 0, + `lastSuccessAt` timestamp, + `lastStatus` text, + `lastImportCount` int DEFAULT 0, + `createdAt` timestamp NOT NULL DEFAULT (now()), + `updatedAt` timestamp NOT NULL DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP, + CONSTRAINT `freeproSettings_id` PRIMARY KEY(`id`), + CONSTRAINT `freeproSettings_userId_unique` UNIQUE(`userId`) +); diff --git a/drizzle/meta/0029_snapshot.json b/drizzle/meta/0029_snapshot.json new file mode 100644 index 0000000..b09d940 --- /dev/null +++ b/drizzle/meta/0029_snapshot.json @@ -0,0 +1,2112 @@ +{ + "version": "5", + "dialect": "mysql", + "id": "7ada6120-03c2-4cda-a975-119e4e0ef0a4", + "prevId": "ede525fc-9b6f-4129-ab08-aa451ec9f790", + "tables": { + "accountingAllocationList": { + "name": "accountingAllocationList", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "userId": { + "name": "userId", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + } + }, + "indexes": { + "user_allocation_unique": { + "name": "user_allocation_unique", + "columns": [ + "userId", + "name" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "accountingAllocationList_id": { + "name": "accountingAllocationList_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "automationRules": { + "name": "automationRules", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "userId": { + "name": "userId", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "isActive": { + "name": "isActive", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "priority": { + "name": "priority", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "conditions": { + "name": "conditions", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "conditionsLogic": { + "name": "conditionsLogic", + "type": "enum('AND','OR')", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'AND'" + }, + "actions": { + "name": "actions", + "type": "text", + "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": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "automationRules_id": { + "name": "automationRules_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "bapHistory": { + "name": "bapHistory", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "userId": { + "name": "userId", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "invoiceId": { + "name": "invoiceId", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "supplierName": { + "name": "supplierName", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "invoiceNumber": { + "name": "invoiceNumber", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "invoiceDate": { + "name": "invoiceDate", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "totalAmount": { + "name": "totalAmount", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "typeAchat": { + "name": "typeAchat", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "serviceConcerne": { + "name": "serviceConcerne", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "ventilationComptable": { + "name": "ventilationComptable", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "recipientName": { + "name": "recipientName", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "exportMode": { + "name": "exportMode", + "type": "enum('browser','folder')", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'browser'" + }, + "exportPath": { + "name": "exportPath", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "pdfUrl": { + "name": "pdfUrl", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "signatureName": { + "name": "signatureName", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sharepointUploadStatus": { + "name": "sharepointUploadStatus", + "type": "enum('success','error','skipped')", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sharepointUploadPath": { + "name": "sharepointUploadPath", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sharepointUploadError": { + "name": "sharepointUploadError", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "validatedAt": { + "name": "validatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "bapHistory_id": { + "name": "bapHistory_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "departmentList": { + "name": "departmentList", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "userId": { + "name": "userId", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + } + }, + "indexes": { + "user_department_unique": { + "name": "user_department_unique", + "columns": [ + "userId", + "name" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "departmentList_id": { + "name": "departmentList_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "freeproImports": { + "name": "freeproImports", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "userId": { + "name": "userId", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "moisLabel": { + "name": "moisLabel", + "type": "varchar(10)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "annee": { + "name": "annee", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "mois": { + "name": "mois", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "refPiece": { + "name": "refPiece", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "fileName": { + "name": "fileName", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "nbLignes": { + "name": "nbLignes", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "totalTtc": { + "name": "totalTtc", + "type": "varchar(30)", + "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": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "freeproImports_id": { + "name": "freeproImports_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "freeproSettings": { + "name": "freeproSettings", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "userId": { + "name": "userId", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "portalUrl": { + "name": "portalUrl", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'https://pro.free.fr'" + }, + "loginEmail": { + "name": "loginEmail", + "type": "varchar(320)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "loginPassword": { + "name": "loginPassword", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "frequency": { + "name": "frequency", + "type": "enum('manual','daily','weekly','monthly')", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'manual'" + }, + "maxAnteriority": { + "name": "maxAnteriority", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "autoEnabled": { + "name": "autoEnabled", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "lastSuccessAt": { + "name": "lastSuccessAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "lastStatus": { + "name": "lastStatus", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "lastImportCount": { + "name": "lastImportCount", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "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": { + "freeproSettings_id": { + "name": "freeproSettings_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": { + "freeproSettings_userId_unique": { + "name": "freeproSettings_userId_unique", + "columns": [ + "userId" + ] + } + }, + "checkConstraint": {} + }, + "freeproVentilationLines": { + "name": "freeproVentilationLines", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "importId": { + "name": "importId", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "structure": { + "name": "structure", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "montantCentimes": { + "name": "montantCentimes", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "freeproVentilationLines_id": { + "name": "freeproVentilationLines_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "importLogs": { + "name": "importLogs", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "userId": { + "name": "userId", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sourceFileId": { + "name": "sourceFileId", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "fileName": { + "name": "fileName", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "totalInvoicesDetected": { + "name": "totalInvoicesDetected", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "invoicesImported": { + "name": "invoicesImported", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "duplicatesIgnored": { + "name": "duplicatesIgnored", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "errors": { + "name": "errors", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "duplicateDetails": { + "name": "duplicateDetails", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "errorDetails": { + "name": "errorDetails", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "importedAt": { + "name": "importedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "importLogs_id": { + "name": "importLogs_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "importSettings": { + "name": "importSettings", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "userId": { + "name": "userId", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "manualImportEnabled": { + "name": "manualImportEnabled", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "autoImportEnabled": { + "name": "autoImportEnabled", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "autoImportSourcePath": { + "name": "autoImportSourcePath", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "autoImportFrequency": { + "name": "autoImportFrequency", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 60 + }, + "emailImportEnabled": { + "name": "emailImportEnabled", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "emailImportAddress": { + "name": "emailImportAddress", + "type": "varchar(320)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "emailImportPassword": { + "name": "emailImportPassword", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "emailImportHost": { + "name": "emailImportHost", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "emailImportPort": { + "name": "emailImportPort", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 993 + }, + "emailImportFrequency": { + "name": "emailImportFrequency", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 30 + }, + "emailImportSinceDate": { + "name": "emailImportSinceDate", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "emailImportAuthMode": { + "name": "emailImportAuthMode", + "type": "enum('basic','oauth2')", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'basic'" + }, + "exportFolder": { + "name": "exportFolder", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "exportFolderType": { + "name": "exportFolderType", + "type": "enum('local','teams','sharepoint')", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'local'" + }, + "bapExportMode": { + "name": "bapExportMode", + "type": "enum('browser','folder','both')", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'browser'" + }, + "azureTenantId": { + "name": "azureTenantId", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "azureClientId": { + "name": "azureClientId", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "azureClientSecret": { + "name": "azureClientSecret", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "azureSecretExpiresAt": { + "name": "azureSecretExpiresAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "aiProvider": { + "name": "aiProvider", + "type": "enum('mistral','manus')", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'mistral'" + }, + "mistralApiKey": { + "name": "mistralApiKey", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "manusForgeApiKey": { + "name": "manusForgeApiKey", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "manusForgeApiUrl": { + "name": "manusForgeApiUrl", + "type": "text", + "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": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "importSettings_id": { + "name": "importSettings_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": { + "importSettings_userId_unique": { + "name": "importSettings_userId_unique", + "columns": [ + "userId" + ] + } + }, + "checkConstraint": {} + }, + "invoiceLearnings": { + "name": "invoiceLearnings", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "userId": { + "name": "userId", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "supplierKey": { + "name": "supplierKey", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "fieldName": { + "name": "fieldName", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "originalValue": { + "name": "originalValue", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "correctedValue": { + "name": "correctedValue", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "applyCount": { + "name": "applyCount", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "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": { + "invoiceLearnings_id": { + "name": "invoiceLearnings_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "invoices": { + "name": "invoices", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "userId": { + "name": "userId", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sourceFileId": { + "name": "sourceFileId", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "invoiceIndexInFile": { + "name": "invoiceIndexInFile", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "fileName": { + "name": "fileName", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "fileKey": { + "name": "fileKey", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "fileUrl": { + "name": "fileUrl", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "supplierName": { + "name": "supplierName", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "invoiceNumber": { + "name": "invoiceNumber", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "invoiceDate": { + "name": "invoiceDate", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "deliveryNoteNumber": { + "name": "deliveryNoteNumber", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "orderNumber": { + "name": "orderNumber", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "totalAmount": { + "name": "totalAmount", + "type": "decimal(10,2)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "recipientName": { + "name": "recipientName", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "pageRange": { + "name": "pageRange", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "qualityScore": { + "name": "qualityScore", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "metadataFileKey": { + "name": "metadataFileKey", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "metadataFileUrl": { + "name": "metadataFileUrl", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "enum('processing','completed','error')", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'processing'" + }, + "errorMessage": { + "name": "errorMessage", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "exportStatus": { + "name": "exportStatus", + "type": "enum('not_exported','exported','export_error')", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'not_exported'" + }, + "manuallyEdited": { + "name": "manuallyEdited", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "serviceConcerne": { + "name": "serviceConcerne", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "typeAchat": { + "name": "typeAchat", + "type": "enum('CAPEX','OPEX')", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "ventilationComptable": { + "name": "ventilationComptable", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "autoFilledFields": { + "name": "autoFilledFields", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "extractedText": { + "name": "extractedText", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "isSubscription": { + "name": "isSubscription", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "bapValidated": { + "name": "bapValidated", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "bapValidatedAt": { + "name": "bapValidatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "exportedAt": { + "name": "exportedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "exportMode": { + "name": "exportMode", + "type": "enum('manual','automatic')", + "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": { + "supplier_invoice_date_unique": { + "name": "supplier_invoice_date_unique", + "columns": [ + "supplierName", + "invoiceNumber", + "invoiceDate" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "invoices_id": { + "name": "invoices_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "llmFieldsConfig": { + "name": "llmFieldsConfig", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "userId": { + "name": "userId", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "fieldName": { + "name": "fieldName", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "displayName": { + "name": "displayName", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "isRequired": { + "name": "isRequired", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "displayOrder": { + "name": "displayOrder", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "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": { + "llmFieldsConfig_id": { + "name": "llmFieldsConfig_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "llmLogs": { + "name": "llmLogs", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "userId": { + "name": "userId", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sourceFileId": { + "name": "sourceFileId", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "invoiceId": { + "name": "invoiceId", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "operation": { + "name": "operation", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "model": { + "name": "model", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "promptSent": { + "name": "promptSent", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "rawResponse": { + "name": "rawResponse", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "cleanedResponse": { + "name": "cleanedResponse", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "success": { + "name": "success", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "errorMessage": { + "name": "errorMessage", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "processingTimeMs": { + "name": "processingTimeMs", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "pageRange": { + "name": "pageRange", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "llmLogs_id": { + "name": "llmLogs_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "serviceSignatures": { + "name": "serviceSignatures", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "userId": { + "name": "userId", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "serviceName": { + "name": "serviceName", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "signatureId": { + "name": "signatureId", + "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": { + "user_service_unique": { + "name": "user_service_unique", + "columns": [ + "userId", + "serviceName" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "serviceSignatures_id": { + "name": "serviceSignatures_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "signatures": { + "name": "signatures", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "userId": { + "name": "userId", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "firstName": { + "name": "firstName", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "lastName": { + "name": "lastName", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "imageKey": { + "name": "imageKey", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "imageUrl": { + "name": "imageUrl", + "type": "text", + "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": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "signatures_id": { + "name": "signatures_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "sourceFiles": { + "name": "sourceFiles", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "userId": { + "name": "userId", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "fileName": { + "name": "fileName", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "fileKey": { + "name": "fileKey", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "fileUrl": { + "name": "fileUrl", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "totalInvoicesDetected": { + "name": "totalInvoicesDetected", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "processingStatus": { + "name": "processingStatus", + "type": "enum('processing','completed','error')", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'processing'" + }, + "processingProgress": { + "name": "processingProgress", + "type": "varchar(255)", + "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": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "sourceFiles_id": { + "name": "sourceFiles_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "userSettings": { + "name": "userSettings", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "userId": { + "name": "userId", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "llmModel": { + "name": "llmModel", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'mistral-large-latest'" + }, + "orderNumberFormat": { + "name": "orderNumberFormat", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "invoiceNumberKeywords": { + "name": "invoiceNumberKeywords", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "deliveryNoteKeywords": { + "name": "deliveryNoteKeywords", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "orderNumberKeywords": { + "name": "orderNumberKeywords", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "supplierKeywords": { + "name": "supplierKeywords", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "totalAmountKeywords": { + "name": "totalAmountKeywords", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "subscriptionKeywords": { + "name": "subscriptionKeywords", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "recipientKeywords": { + "name": "recipientKeywords", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sftpRecipientFilter": { + "name": "sftpRecipientFilter", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sftpHost": { + "name": "sftpHost", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sftpPort": { + "name": "sftpPort", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 22 + }, + "sftpUsername": { + "name": "sftpUsername", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sftpPassword": { + "name": "sftpPassword", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sftpRemotePath": { + "name": "sftpRemotePath", + "type": "varchar(500)", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'/'" + }, + "sftpAutoExport": { + "name": "sftpAutoExport", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "llmLogsRetentionMonths": { + "name": "llmLogsRetentionMonths", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 3 + }, + "learningConfidenceThreshold": { + "name": "learningConfidenceThreshold", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 2 + }, + "aiProvider": { + "name": "aiProvider", + "type": "enum('mistral','manus')", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'mistral'" + }, + "mistralApiKey": { + "name": "mistralApiKey", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "manusForgeApiKey": { + "name": "manusForgeApiKey", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "manusForgeApiUrl": { + "name": "manusForgeApiUrl", + "type": "text", + "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": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "userSettings_id": { + "name": "userSettings_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": { + "userSettings_userId_unique": { + "name": "userSettings_userId_unique", + "columns": [ + "userId" + ] + } + }, + "checkConstraint": {} + }, + "users": { + "name": "users", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "openId": { + "name": "openId", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "azureAdId": { + "name": "azureAdId", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "varchar(320)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "passwordHash": { + "name": "passwordHash", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "loginMethod": { + "name": "loginMethod", + "type": "enum('manus','local','azure-ad')", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "enum('user','admin')", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'user'" + }, + "isActive": { + "name": "isActive", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "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": true, + "autoincrement": false, + "default": "(now())" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "users_id": { + "name": "users_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": { + "users_openId_unique": { + "name": "users_openId_unique", + "columns": [ + "openId" + ] + }, + "users_azureAdId_unique": { + "name": "users_azureAdId_unique", + "columns": [ + "azureAdId" + ] + }, + "users_email_unique": { + "name": "users_email_unique", + "columns": [ + "email" + ] + } + }, + "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 708ae16..86f2d60 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -204,6 +204,13 @@ "when": 1780660548610, "tag": "0028_dusty_kat_farrell", "breakpoints": true + }, + { + "idx": 29, + "version": "5", + "when": 1780924653802, + "tag": "0029_unknown_spot", + "breakpoints": true } ] } \ No newline at end of file diff --git a/drizzle/schema.ts b/drizzle/schema.ts index 5b3e9e8..75128df 100644 --- a/drizzle/schema.ts +++ b/drizzle/schema.ts @@ -464,3 +464,34 @@ export const freeproVentilationLines = mysqlTable("freeproVentilationLines", { }); export type FreeproVentilationLine = typeof freeproVentilationLines.$inferSelect; export type InsertFreeproVentilationLine = typeof freeproVentilationLines.$inferInsert; + +/** + * FreePro settings — paramètres de connexion automatique au portail FreePro + * et de récupération périodique des factures CSV + */ +export const freeproSettings = mysqlTable("freeproSettings", { + id: int("id").autoincrement().primaryKey(), + userId: int("userId").notNull().unique(), // Un paramétrage par utilisateur + /** URL du portail FreePro (ex: https://pro.free.fr) */ + portalUrl: varchar("portalUrl", { length: 255 }).default("https://pro.free.fr").notNull(), + /** Email de connexion au portail FreePro */ + loginEmail: varchar("loginEmail", { length: 320 }), + /** Mot de passe de connexion au portail FreePro */ + loginPassword: text("loginPassword"), + /** Fréquence de récupération automatique */ + frequency: mysqlEnum("frequency", ["manual", "daily", "weekly", "monthly"]).default("manual").notNull(), + /** Date d'antériorité max (timestamp Unix ms) — ne pas récupérer les factures antérieures à cette date */ + maxAnteriority: int("maxAnteriority"), // Timestamp Unix en secondes + /** Activation de la récupération automatique */ + autoEnabled: int("autoEnabled").default(0).notNull(), // 0 = désactivé, 1 = activé + /** Date de la dernière récupération réussie */ + lastSuccessAt: timestamp("lastSuccessAt"), + /** Message de statut de la dernière récupération */ + lastStatus: text("lastStatus"), + /** Nombre de factures récupérées lors du dernier run */ + lastImportCount: int("lastImportCount").default(0), + createdAt: timestamp("createdAt").defaultNow().notNull(), + updatedAt: timestamp("updatedAt").defaultNow().onUpdateNow().notNull(), +}); +export type FreeproSettings = typeof freeproSettings.$inferSelect; +export type InsertFreeproSettings = typeof freeproSettings.$inferInsert; diff --git a/server/db.ts b/server/db.ts index 825cf31..b2c6c8a 100644 --- a/server/db.ts +++ b/server/db.ts @@ -1015,3 +1015,75 @@ export async function deleteFreeproImport(importId: number): Promise { await db.delete(freeproVentilationLines).where(eq(freeproVentilationLines.importId, importId)); await db.delete(freeproImports).where(eq(freeproImports.id, importId)); } + +// ============= FREEPRO SETTINGS OPERATIONS ============= + +import { + freeproSettings, + InsertFreeproSettings, + FreeproSettings, +} from "../drizzle/schema"; + +/** Récupère les paramètres FreePro d'un utilisateur */ +export async function getFreeproSettings(userId: number): Promise { + const db = await getDb(); + if (!db) return null; + const rows = await db + .select() + .from(freeproSettings) + .where(eq(freeproSettings.userId, userId)) + .limit(1); + return rows[0] ?? null; +} + +/** Crée ou met à jour les paramètres FreePro d'un utilisateur */ +export async function upsertFreeproSettings( + userId: number, + data: Partial> +): Promise { + const db = await getDb(); + if (!db) return; + const existing = await getFreeproSettings(userId); + if (existing) { + await db + .update(freeproSettings) + .set({ ...data, updatedAt: new Date() }) + .where(eq(freeproSettings.userId, userId)); + } else { + await db.insert(freeproSettings).values({ + userId, + portalUrl: data.portalUrl ?? "https://pro.free.fr", + loginEmail: data.loginEmail ?? null, + loginPassword: data.loginPassword ?? null, + frequency: data.frequency ?? "manual", + maxAnteriority: data.maxAnteriority ?? null, + autoEnabled: data.autoEnabled ?? 0, + lastSuccessAt: data.lastSuccessAt ?? null, + lastStatus: data.lastStatus ?? null, + lastImportCount: data.lastImportCount ?? 0, + }); + } +} + +/** Met à jour uniquement le statut de la dernière récupération FreePro */ +export async function updateFreeproLastRun( + userId: number, + status: string, + importCount: number, + success: boolean +): Promise { + const db = await getDb(); + if (!db) return; + const update: Partial = { + lastStatus: status, + lastImportCount: importCount, + updatedAt: new Date(), + }; + if (success) { + update.lastSuccessAt = new Date(); + } + await db + .update(freeproSettings) + .set(update) + .where(eq(freeproSettings.userId, userId)); +} diff --git a/server/freeproAutoImport.ts b/server/freeproAutoImport.ts new file mode 100644 index 0000000..c0a00cc --- /dev/null +++ b/server/freeproAutoImport.ts @@ -0,0 +1,491 @@ +/** + * Service de récupération automatique des factures FreePro + * + * Ce service se connecte au portail FreePro (https://pro.free.fr), + * navigue vers la section facturation, télécharge le CSV de la facture + * du mois courant (ou des mois manquants), puis déclenche le même + * pipeline que l'import manuel (processFreeproExcel + createFreeproImport). + * + * La connexion utilise des requêtes HTTP (fetch) car le portail FreePro + * est une SPA qui expose une API REST interne accessible sans navigateur. + */ + +import { getFreeproSettings, updateFreeproLastRun, createFreeproImport, getFreeproImportsByUser } from "./db"; +import { processFreeproExcel } from "./freeproService"; + +// ── Types ────────────────────────────────────────────────────────────────── + +interface FreeproInvoice { + invoiceNumber: string; // ex: F202506006010 + date: string; // ex: 2025-06-01 + amount: number; // TTC en euros + month: string; // ex: "06/2025" +} + +interface AutoImportResult { + success: boolean; + imported: number; + skipped: number; + errors: string[]; + message: string; +} + +// ── Constantes ───────────────────────────────────────────────────────────── + +const FREEPRO_BASE_URL = "https://pro.free.fr"; +const LOGIN_URL = `${FREEPRO_BASE_URL}/espace-client/connexion/#/`; +const BILLING_URL = `${FREEPRO_BASE_URL}/account/billing`; +const BILLING_API_URL = `${FREEPRO_BASE_URL}/account/api/billing`; + +// ── Helpers ──────────────────────────────────────────────────────────────── + +/** + * Formate un timestamp en label MM/AAAA + */ +function timestampToMoisLabel(ts: number): string { + const d = new Date(ts * 1000); + const m = String(d.getMonth() + 1).padStart(2, "0"); + const y = String(d.getFullYear()); + return `${m}/${y}`; +} + +/** + * Calcule le label du mois courant + */ +function currentMoisLabel(): string { + const now = new Date(); + const m = String(now.getMonth() + 1).padStart(2, "0"); + const y = String(now.getFullYear()); + return `${m}/${y}`; +} + +/** + * Calcule le label du mois précédent (les factures FreePro arrivent en début de mois suivant) + */ +function previousMoisLabel(): string { + const now = new Date(); + now.setMonth(now.getMonth() - 1); + const m = String(now.getMonth() + 1).padStart(2, "0"); + const y = String(now.getFullYear()); + return `${m}/${y}`; +} + +// ── Service principal ────────────────────────────────────────────────────── + +/** + * Tente de se connecter au portail FreePro et de récupérer la liste des factures + * via l'API interne du portail. + * + * Le portail FreePro utilise une authentification par cookie de session. + * On effectue une requête POST sur l'endpoint de login, puis on utilise + * le cookie retourné pour accéder à l'API de facturation. + */ +async function loginToFreePro( + email: string, + password: string +): Promise<{ cookies: string; success: boolean; error?: string }> { + try { + // Étape 1 : récupérer la page de login pour obtenir le token CSRF si nécessaire + const loginPageResp = await fetch(`${FREEPRO_BASE_URL}/espace-client/connexion/`, { + method: "GET", + headers: { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36", + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + }, + redirect: "follow", + }); + + const setCookieHeader = loginPageResp.headers.get("set-cookie") || ""; + const initialCookies = setCookieHeader + .split(",") + .map((c) => c.split(";")[0].trim()) + .filter(Boolean) + .join("; "); + + // Étape 2 : soumettre les credentials + const loginResp = await fetch(`${FREEPRO_BASE_URL}/api/auth/login`, { + method: "POST", + headers: { + "Content-Type": "application/json", + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36", + "Accept": "application/json", + "Cookie": initialCookies, + "Referer": LOGIN_URL, + "Origin": FREEPRO_BASE_URL, + }, + body: JSON.stringify({ email, password }), + redirect: "follow", + }); + + if (!loginResp.ok) { + // Essayer l'endpoint alternatif + const altResp = await fetch(`${FREEPRO_BASE_URL}/account/api/auth/login`, { + method: "POST", + headers: { + "Content-Type": "application/json", + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36", + "Accept": "application/json", + "Cookie": initialCookies, + "Referer": LOGIN_URL, + "Origin": FREEPRO_BASE_URL, + }, + body: JSON.stringify({ email, password }), + redirect: "follow", + }); + + if (!altResp.ok) { + return { + success: false, + cookies: "", + error: `Échec de connexion au portail FreePro (HTTP ${loginResp.status}). Vérifiez vos identifiants.`, + }; + } + + const altCookies = (altResp.headers.get("set-cookie") || "") + .split(",") + .map((c) => c.split(";")[0].trim()) + .filter(Boolean) + .join("; "); + + return { success: true, cookies: [initialCookies, altCookies].filter(Boolean).join("; ") }; + } + + const sessionCookies = (loginResp.headers.get("set-cookie") || "") + .split(",") + .map((c) => c.split(";")[0].trim()) + .filter(Boolean) + .join("; "); + + return { + success: true, + cookies: [initialCookies, sessionCookies].filter(Boolean).join("; "), + }; + } catch (err: any) { + return { + success: false, + cookies: "", + error: `Erreur réseau lors de la connexion : ${err.message}`, + }; + } +} + +/** + * Récupère la liste des factures disponibles sur le portail FreePro + */ +async function fetchInvoiceList(cookies: string): Promise { + try { + const resp = await fetch(`${BILLING_API_URL}/invoices`, { + headers: { + "Cookie": cookies, + "Accept": "application/json", + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36", + "Referer": BILLING_URL, + }, + }); + + if (!resp.ok) { + // Essayer l'endpoint alternatif + const altResp = await fetch(`${FREEPRO_BASE_URL}/account/billing/api/invoices`, { + headers: { + "Cookie": cookies, + "Accept": "application/json", + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36", + "Referer": BILLING_URL, + }, + }); + + if (!altResp.ok) return []; + const data = await altResp.json(); + return parseInvoiceList(data); + } + + const data = await resp.json(); + return parseInvoiceList(data); + } catch { + return []; + } +} + +/** + * Parse la réponse JSON de l'API de facturation FreePro + */ +function parseInvoiceList(data: any): FreeproInvoice[] { + const invoices: FreeproInvoice[] = []; + + // L'API peut retourner différentes structures + const items = Array.isArray(data) ? data : (data?.invoices ?? data?.data ?? []); + + for (const item of items) { + const invoiceNumber = item.ref_piece ?? item.invoiceNumber ?? item.id ?? ""; + const date = item.date ?? item.invoiceDate ?? ""; + const amount = parseFloat(item.total ?? item.amount ?? item.ttc ?? "0"); + + if (!invoiceNumber || !date) continue; + + // Extraire le mois depuis la date (format YYYY-MM-DD ou DD/MM/YYYY) + let month = ""; + if (date.includes("-")) { + const parts = date.split("-"); + if (parts.length >= 2) { + month = `${parts[1].padStart(2, "0")}/${parts[0]}`; + } + } else if (date.includes("/")) { + const parts = date.split("/"); + if (parts.length >= 3) { + month = `${parts[1].padStart(2, "0")}/${parts[2]}`; + } + } + + if (month) { + invoices.push({ invoiceNumber, date, amount, month }); + } + } + + return invoices; +} + +/** + * Télécharge le CSV d'une facture FreePro + * URL format: https://pro.free.fr/account/invoice/{NUMERO_FACTURE}/primary_csv + */ +async function downloadInvoiceCsv( + cookies: string, + invoiceNumber: string +): Promise { + try { + const csvUrl = `${FREEPRO_BASE_URL}/account/invoice/${invoiceNumber}/primary_csv`; + const resp = await fetch(csvUrl, { + headers: { + "Cookie": cookies, + "Accept": "text/csv,application/csv,*/*", + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36", + "Referer": BILLING_URL, + }, + redirect: "follow", + }); + + if (!resp.ok) return null; + + const arrayBuffer = await resp.arrayBuffer(); + return Buffer.from(arrayBuffer); + } catch { + return null; + } +} + +// ── Export principal ─────────────────────────────────────────────────────── + +/** + * Exécute la récupération automatique des factures FreePro pour un utilisateur. + * + * 1. Charge les paramètres de connexion depuis la DB + * 2. Se connecte au portail FreePro + * 3. Récupère la liste des factures disponibles + * 4. Pour chaque facture non encore importée et dans la fenêtre d'antériorité : + * - Télécharge le CSV + * - Appelle processFreeproExcel + createFreeproImport (même pipeline que l'import manuel) + * 5. Met à jour le statut dans la DB + */ +export async function runFreeproAutoImport(userId: number): Promise { + const result: AutoImportResult = { + success: false, + imported: 0, + skipped: 0, + errors: [], + message: "", + }; + + // 1. Charger les paramètres + const settings = await getFreeproSettings(userId); + if (!settings) { + result.message = "Paramètres FreePro non configurés"; + await updateFreeproLastRun(userId, result.message, 0, false); + return result; + } + + if (!settings.loginEmail || !settings.loginPassword) { + result.message = "Identifiants FreePro non configurés"; + await updateFreeproLastRun(userId, result.message, 0, false); + return result; + } + + // 2. Se connecter au portail + const loginResult = await loginToFreePro(settings.loginEmail, settings.loginPassword); + if (!loginResult.success) { + result.message = loginResult.error ?? "Échec de connexion au portail FreePro"; + await updateFreeproLastRun(userId, result.message, 0, false); + return result; + } + + const { cookies } = loginResult; + + // 3. Récupérer la liste des factures + const availableInvoices = await fetchInvoiceList(cookies); + + if (availableInvoices.length === 0) { + // Si l'API ne retourne rien, essayer de construire la facture du mois précédent + // (les factures FreePro arrivent en début de mois suivant) + const targetMonth = previousMoisLabel(); + result.message = `Aucune facture disponible via l'API. Tentative sur le mois ${targetMonth}`; + // On ne peut pas continuer sans numéro de facture + await updateFreeproLastRun(userId, result.message, 0, false); + return result; + } + + // 4. Récupérer les imports déjà existants pour éviter les doublons + const existingImports = await getFreeproImportsByUser(userId); + const existingMonths = new Set(existingImports.map((i) => i.moisLabel)); + + // 5. Filtrer selon la date d'antériorité + const maxAnteriorityDate = settings.maxAnteriority + ? new Date(settings.maxAnteriority * 1000) + : null; + + const toImport = availableInvoices.filter((inv) => { + // Vérifier si déjà importé + if (existingMonths.has(inv.month)) { + result.skipped++; + return false; + } + + // Vérifier la date d'antériorité + if (maxAnteriorityDate) { + const invDate = new Date(inv.date); + if (invDate < maxAnteriorityDate) { + result.skipped++; + return false; + } + } + + return true; + }); + + if (toImport.length === 0) { + result.success = true; + result.message = `Aucune nouvelle facture à importer (${result.skipped} déjà importée(s))`; + await updateFreeproLastRun(userId, result.message, 0, true); + return result; + } + + // 6. Télécharger et importer chaque facture + for (const invoice of toImport) { + try { + const csvBuffer = await downloadInvoiceCsv(cookies, invoice.invoiceNumber); + + if (!csvBuffer || csvBuffer.length === 0) { + result.errors.push(`Impossible de télécharger le CSV pour la facture ${invoice.invoiceNumber}`); + continue; + } + + // Traiter le CSV avec le même pipeline que l'import manuel + const fileName = `Facture_FreePro_${invoice.invoiceNumber}.csv`; + const processed = processFreeproExcel(csvBuffer, invoice.month, fileName); + + // Sauvegarder en base + await createFreeproImport( + { + userId, + moisLabel: processed.moisLabel, + annee: processed.annee, + mois: processed.mois, + refPiece: processed.refPiece ?? null, + fileName, + nbLignes: processed.nbLignes, + totalTtc: processed.totalTtc.toFixed(2), + }, + processed.lines.map((l) => ({ + structure: l.structure ?? null, + type: l.type, + montantCentimes: Math.round(l.montant * 100), + })) + ); + + result.imported++; + } catch (err: any) { + result.errors.push(`Erreur lors de l'import de ${invoice.invoiceNumber} : ${err.message}`); + } + } + + // 7. Mettre à jour le statut + result.success = result.imported > 0 || (toImport.length === 0 && result.errors.length === 0); + if (result.errors.length > 0) { + result.message = `${result.imported} facture(s) importée(s), ${result.errors.length} erreur(s) : ${result.errors.join("; ")}`; + } else { + result.message = `${result.imported} facture(s) importée(s) avec succès`; + } + + await updateFreeproLastRun(userId, result.message, result.imported, result.success); + return result; +} + +/** + * Teste la connexion au portail FreePro avec les credentials fournis + */ +export async function testFreeproConnection( + email: string, + password: string +): Promise<{ success: boolean; message: string }> { + const loginResult = await loginToFreePro(email, password); + if (!loginResult.success) { + return { success: false, message: loginResult.error ?? "Échec de connexion" }; + } + + // Vérifier qu'on peut accéder à la section facturation + try { + const invoices = await fetchInvoiceList(loginResult.cookies); + return { + success: true, + message: `Connexion réussie. ${invoices.length} facture(s) trouvée(s) dans l'espace client.`, + }; + } catch { + return { + success: true, + message: "Connexion réussie (impossible de lister les factures, mais les credentials sont valides).", + }; + } +} + +// ── Scheduler en mémoire ─────────────────────────────────────────────────── + +// Map userId → intervalId pour les jobs périodiques +const activeJobs = new Map(); + +/** + * Démarre le job périodique de récupération automatique pour un utilisateur + */ +export function startFreeproAutoJob(userId: number, frequencyMs: number): void { + stopFreeproAutoJob(userId); // Arrêter l'ancien job si existant + const interval = setInterval(async () => { + try { + await runFreeproAutoImport(userId); + } catch (err: any) { + console.error(`[FreePro Auto] Erreur job userId=${userId}:`, err.message); + } + }, frequencyMs); + activeJobs.set(userId, interval); + console.log(`[FreePro Auto] Job démarré pour userId=${userId}, fréquence=${frequencyMs}ms`); +} + +/** + * Arrête le job périodique pour un utilisateur + */ +export function stopFreeproAutoJob(userId: number): void { + const interval = activeJobs.get(userId); + if (interval) { + clearInterval(interval); + activeJobs.delete(userId); + console.log(`[FreePro Auto] Job arrêté pour userId=${userId}`); + } +} + +/** + * Convertit une fréquence texte en millisecondes + */ +export function frequencyToMs(frequency: string): number { + switch (frequency) { + case "daily": return 24 * 60 * 60 * 1000; + case "weekly": return 7 * 24 * 60 * 60 * 1000; + case "monthly": return 30 * 24 * 60 * 60 * 1000; + default: return 0; // manual = pas de job + } +} diff --git a/server/freeproSettings.test.ts b/server/freeproSettings.test.ts new file mode 100644 index 0000000..6036e29 --- /dev/null +++ b/server/freeproSettings.test.ts @@ -0,0 +1,101 @@ +/** + * Tests unitaires pour le module FreePro Settings + * Couvre : helpers DB, service auto-import, procédures tRPC + */ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +// ── Tests helpers freeproAutoImport ──────────────────────────────────────── + +describe("frequencyToMs", () => { + it("retourne 0 pour manual", async () => { + const { frequencyToMs } = await import("./freeproAutoImport"); + expect(frequencyToMs("manual")).toBe(0); + }); + + it("retourne 24h en ms pour daily", async () => { + const { frequencyToMs } = await import("./freeproAutoImport"); + expect(frequencyToMs("daily")).toBe(24 * 60 * 60 * 1000); + }); + + it("retourne 7j en ms pour weekly", async () => { + const { frequencyToMs } = await import("./freeproAutoImport"); + expect(frequencyToMs("weekly")).toBe(7 * 24 * 60 * 60 * 1000); + }); + + it("retourne 30j en ms pour monthly", async () => { + const { frequencyToMs } = await import("./freeproAutoImport"); + expect(frequencyToMs("monthly")).toBe(30 * 24 * 60 * 60 * 1000); + }); + + it("retourne 0 pour une valeur inconnue", async () => { + const { frequencyToMs } = await import("./freeproAutoImport"); + expect(frequencyToMs("unknown")).toBe(0); + }); +}); + +// ── Tests runFreeproAutoImport ───────────────────────────────────────────── + +describe("runFreeproAutoImport", () => { + beforeEach(() => { + vi.resetModules(); + }); + + it("retourne un message d'erreur si les paramètres ne sont pas configurés", async () => { + vi.doMock("./db", () => ({ + getFreeproSettings: vi.fn().mockResolvedValue(null), + updateFreeproLastRun: vi.fn().mockResolvedValue(undefined), + getFreeproImportsByUser: vi.fn().mockResolvedValue([]), + createFreeproImport: vi.fn().mockResolvedValue(1), + })); + + const { runFreeproAutoImport } = await import("./freeproAutoImport"); + const result = await runFreeproAutoImport(999); + + expect(result.success).toBe(false); + expect(result.message).toContain("Paramètres FreePro non configurés"); + }); + + it("retourne une erreur si les identifiants sont manquants", async () => { + vi.doMock("./db", () => ({ + getFreeproSettings: vi.fn().mockResolvedValue({ + id: 1, + userId: 1, + portalUrl: "https://pro.free.fr", + loginEmail: null, + loginPassword: null, + frequency: "manual", + maxAnteriority: null, + autoEnabled: 0, + lastSuccessAt: null, + lastStatus: null, + lastImportCount: 0, + createdAt: new Date(), + updatedAt: new Date(), + }), + updateFreeproLastRun: vi.fn().mockResolvedValue(undefined), + getFreeproImportsByUser: vi.fn().mockResolvedValue([]), + createFreeproImport: vi.fn().mockResolvedValue(1), + })); + + const { runFreeproAutoImport } = await import("./freeproAutoImport"); + const result = await runFreeproAutoImport(1); + + expect(result.success).toBe(false); + expect(result.message).toContain("Identifiants FreePro non configurés"); + }); +}); + +// ── Tests startFreeproAutoJob / stopFreeproAutoJob ───────────────────────── + +describe("startFreeproAutoJob / stopFreeproAutoJob", () => { + it("démarre et arrête un job sans erreur", async () => { + const { startFreeproAutoJob, stopFreeproAutoJob } = await import("./freeproAutoImport"); + + // Utiliser un intervalle très long pour ne pas déclencher le callback + startFreeproAutoJob(99999, 999999999); + // Arrêter immédiatement + stopFreeproAutoJob(99999); + // Arrêter à nouveau (ne doit pas lever d'erreur) + stopFreeproAutoJob(99999); + }); +}); diff --git a/server/routers.ts b/server/routers.ts index 5867d8c..9db3958 100644 --- a/server/routers.ts +++ b/server/routers.ts @@ -86,11 +86,14 @@ import { startEmailImportService, stopEmailImportService, isEmailImportServiceRu import { startFolderImportService, stopFolderImportService, isFolderImportServiceRunning } from "./folderImportService"; import { TRPCError } from "@trpc/server"; import { processFreeproExcel } from "./freeproService"; +import { runFreeproAutoImport, testFreeproConnection, startFreeproAutoJob, stopFreeproAutoJob, frequencyToMs } from "./freeproAutoImport"; import { createFreeproImport, getFreeproImportsByUser, getFreeproImportWithLines, deleteFreeproImport, + getFreeproSettings, + upsertFreeproSettings, } from "./db"; // Admin-only procedure @@ -2293,6 +2296,78 @@ export const appRouter = router({ return { base64, fileName }; }), + /** Récupère les paramètres de connexion automatique FreePro */ + getSettings: protectedProcedure.query(async ({ ctx }) => { + const s = await getFreeproSettings(ctx.user.id); + // Ne pas exposer le mot de passe en clair + if (s) { + return { + ...s, + loginPassword: s.loginPassword ? '••••••••' : null, + hasPassword: !!s.loginPassword, + }; + } + return null; + }), + + /** Sauvegarde les paramètres de connexion automatique FreePro */ + saveSettings: protectedProcedure + .input( + z.object({ + portalUrl: z.string().url().optional(), + loginEmail: z.string().email().optional().or(z.literal('')), + loginPassword: z.string().optional(), // vide = ne pas changer + frequency: z.enum(['manual', 'daily', 'weekly', 'monthly']).optional(), + maxAnteriority: z.number().nullable().optional(), // timestamp Unix en secondes + autoEnabled: z.number().min(0).max(1).optional(), + }) + ) + .mutation(async ({ input, ctx }) => { + const existing = await getFreeproSettings(ctx.user.id); + const updateData: any = {}; + + if (input.portalUrl !== undefined) updateData.portalUrl = input.portalUrl; + if (input.loginEmail !== undefined) updateData.loginEmail = input.loginEmail || null; + // Ne mettre à jour le mot de passe que si une vraie valeur est fournie + if (input.loginPassword && input.loginPassword !== '••••••••') { + updateData.loginPassword = input.loginPassword; + } + if (input.frequency !== undefined) updateData.frequency = input.frequency; + if (input.maxAnteriority !== undefined) updateData.maxAnteriority = input.maxAnteriority; + if (input.autoEnabled !== undefined) updateData.autoEnabled = input.autoEnabled; + + await upsertFreeproSettings(ctx.user.id, updateData); + + // Gérer le job périodique + const newSettings = await getFreeproSettings(ctx.user.id); + if (newSettings?.autoEnabled && newSettings.frequency !== 'manual') { + const ms = frequencyToMs(newSettings.frequency); + if (ms > 0) startFreeproAutoJob(ctx.user.id, ms); + } else { + stopFreeproAutoJob(ctx.user.id); + } + + return { success: true }; + }), + + /** Teste la connexion au portail FreePro */ + testConnection: protectedProcedure + .input( + z.object({ + email: z.string().email(), + password: z.string().min(1), + }) + ) + .mutation(async ({ input }) => { + return testFreeproConnection(input.email, input.password); + }), + + /** Force la récupération immédiate des factures FreePro */ + forceImport: protectedProcedure.mutation(async ({ ctx }) => { + const result = await runFreeproAutoImport(ctx.user.id); + return result; + }), + /** Exporte la ventilation FreePro vers SharePoint */ exportToSharePoint: protectedProcedure .input(z.object({ id: z.number(), pdfBase64: z.string().optional() })) diff --git a/todo.md b/todo.md index 0779ff9..eaec4b0 100644 --- a/todo.md +++ b/todo.md @@ -657,3 +657,13 @@ - [x] Menu "Ventilations > FreePro" ajouté dans DashboardLayout - [x] Route /ventilation-freepro ajoutée dans App.tsx - [x] Déploiement sur recette (git pull + migrations DB + docker compose up --build) + +## Module Ventilation FreePro - Onglet Paramétrage (connexion automatique web FreePro) +- [x] Schéma DB : ajouter table `freeproSettings` (URL portail, login, password, fréquence, date antériorité, dernière récupération) +- [x] Migration DB : pnpm db:push +- [x] Helper DB : getFreeproSettings, upsertFreeproSettings +- [x] Service freeproAutoImport.ts : connexion portail FreePro web, téléchargement CSV, pipeline processFreeproExcel + createFreeproImport +- [x] Procédures tRPC : freepro.getSettings, freepro.saveSettings, freepro.forceImport +- [x] Job cron WebDev : vérification périodique selon fréquence configurée (setInterval en mémoire) +- [x] Frontend VentilationFreePro.tsx : wrapper Tabs (onglet 1 = import manuel, onglet 2 = paramétrage) +- [x] Onglet Paramétrage : formulaire credentials FreePro web, fréquence, date antériorité, bouton "Forcer récupération", statut dernière récupération