Compare commits

...

10 Commits

Author SHA1 Message Date
Manus
827c9ec41e Checkpoint: Ajout de Google Gemini (gemini-2.0-flash) comme 3ème moteur IA : schéma DB mis à jour (enum aiProvider + colonne geminiApiKey dans userSettings et importSettings), llm.ts mis à jour avec routing vers l'API Google AI Studio (compatible OpenAI), fenêtre de configuration IA mise à jour avec 3 cartes (Mistral / Gemini / Manus) et champ clé API Gemini. 2026-07-27 15:55:07 +00:00
Manus
46a61fca0e Checkpoint: Ventilations, automatismes, services et signatures sont maintenant globaux (sans filtre userId). Doublons supprimés en production. 2026-07-13 10:21:46 -04:00
Manus
1951b9b9d1 Checkpoint: Les admins voient désormais toutes les factures (tous userId) : getById, update, delete, validateBAP, devalidateBAP, validateBAPBulk, reprocessSelected, regenerateBapPdf, search, exportToExcel, exportToPdf, exportSFTP, bapHistory.delete/regenerate — tous corrigés pour les admins. 2026-07-13 08:13:49 -04:00
Manus
a994ec2e61 Checkpoint: Fix: colonne azureAdId agrandie à VARCHAR(128) pour accepter les identifiants Azure AD composés (objectId.tenantId). Migration appliquée en production. 2026-07-07 06:20:33 -04:00
Manus
8ee10602d3 Checkpoint: Ajout de la connexion OAuth2 Microsoft 365 (Azure AD) : route callback /api/auth/azure/callback, bouton Microsoft sur la page de login, variables d'environnement Azure AD configurées 2026-07-07 05:54:46 -04:00
Manus
d4c9bf635a Checkpoint: Fix: la procédure deleteAll des importLogs permet maintenant aux admins de supprimer tous les logs (pas seulement les leurs). Correction de deleteAllImportLogs dans db.ts pour accepter userId=null (suppression totale). 2026-06-17 06:20:40 -04:00
Manus
f7d3f6d5b3 Checkpoint: Ajout colonne "Source import" (Email / Dossier / Fichier) dans l'historique des imports : schéma DB migré, backend mis à jour (routers.ts, emailImportService.ts, folderImportService.ts), frontend History.tsx avec badges colorés dans le tableau et le dialog de détails. 2026-06-17 06:11:46 -04:00
Manus
b97efb5c57 Checkpoint: Fix: bouton de téléchargement PDF BAP pour factures validées sans PDF (NOVRH VF11521, EVOLUCARE ET098857). Ajout procédure regenerateBapPdf côté serveur + bouton orange RefreshCw côté frontend pour régénérer le PDF à la volée. Déployé en recette et production. 2026-06-17 03:41:18 -04:00
Manus
756f8ab392 fix: stabiliser validatedInvoiceIds avec useMemo pour bouton téléchargement BAP 2026-06-16 12:29:58 -04:00
Manus
1a09c98ef5 fix: exportStatus mis à jour à 'exported' après validation BAP (unitaire et en masse) 2026-06-16 12:18:40 -04:00
22 changed files with 7027 additions and 48 deletions

1
.gitignore vendored
View File

@@ -105,3 +105,4 @@ temp/
*.db *.db
*.sqlite *.sqlite
*.sqlite3 *.sqlite3
.project-config.json

View File

@@ -0,0 +1,9 @@
{
"query": "\nSELECT i.id, i.supplierName, i.invoiceNumber, i.bapValidated, i.exportStatus, \n bh.id as bapHistoryId, bh.pdfUrl, bh.validatedAt\nFROM invoices i \nLEFT JOIN bapHistory bh ON bh.invoiceId = i.id \nWHERE i.supplierName LIKE '%ALPES%' OR i.supplierName LIKE '%KOESIO%'\nORDER BY i.supplierName;\n",
"command": "mysql --batch --raw --column-names --default-character-set=utf8mb4 --host gateway02.us-east-1.prod.aws.tidbcloud.com --port 4000 --user 4CrrYuB5tme73Qo.bd4328423008 --database fo4DRyBgjsuiigFAgNLuWm --execute \nSELECT i.id, i.supplierName, i.invoiceNumber, i.bapValidated, i.exportStatus, \n bh.id as bapHistoryId, bh.pdfUrl, bh.validatedAt\nFROM invoices i \nLEFT JOIN bapHistory bh ON bh.invoiceId = i.id \nWHERE i.supplierName LIKE '%ALPES%' OR i.supplierName LIKE '%KOESIO%'\nORDER BY i.supplierName;\n",
"rows": [],
"messages": [],
"stdout": "",
"stderr": "",
"execution_time_ms": 287
}

View File

@@ -23,6 +23,9 @@ import {
RotateCcw, RotateCcw,
Package, Package,
X, X,
Upload,
FolderOpen,
Mail,
} from "lucide-react"; } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { toast } from "sonner"; import { toast } from "sonner";
@@ -307,6 +310,7 @@ export default function History() {
<TableRow className="bg-gray-50"> <TableRow className="bg-gray-50">
<TableHead className="font-semibold">Fichier importé</TableHead> <TableHead className="font-semibold">Fichier importé</TableHead>
<TableHead className="font-semibold">Date d'import</TableHead> <TableHead className="font-semibold">Date d'import</TableHead>
<TableHead className="font-semibold text-center">Source</TableHead>
<TableHead className="font-semibold text-center">Détectées</TableHead> <TableHead className="font-semibold text-center">Détectées</TableHead>
<TableHead className="font-semibold text-center">Importées</TableHead> <TableHead className="font-semibold text-center">Importées</TableHead>
<TableHead className="font-semibold text-center">Doublons</TableHead> <TableHead className="font-semibold text-center">Doublons</TableHead>
@@ -326,6 +330,24 @@ export default function History() {
<TableCell className="text-sm text-gray-600"> <TableCell className="text-sm text-gray-600">
{new Date(log.importedAt).toLocaleString("fr-FR")} {new Date(log.importedAt).toLocaleString("fr-FR")}
</TableCell> </TableCell>
<TableCell className="text-center">
{log.importSource === "email" ? (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-700 border border-blue-200">
<Mail className="h-3 w-3" />
Email
</span>
) : log.importSource === "folder" ? (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium bg-purple-100 text-purple-700 border border-purple-200">
<FolderOpen className="h-3 w-3" />
Dossier
</span>
) : (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-700 border border-gray-200">
<Upload className="h-3 w-3" />
Fichier
</span>
)}
</TableCell>
<TableCell className="text-center"> <TableCell className="text-center">
<Badge variant="outline" className="font-mono"> <Badge variant="outline" className="font-mono">
{log.totalInvoicesDetected} {log.totalInvoicesDetected}
@@ -408,6 +430,18 @@ export default function History() {
<p className="text-xs text-gray-500 font-medium mb-1">Date d'import</p> <p className="text-xs text-gray-500 font-medium mb-1">Date d'import</p>
<p className="text-sm font-semibold">{new Date(selectedLog.importedAt).toLocaleString("fr-FR")}</p> <p className="text-sm font-semibold">{new Date(selectedLog.importedAt).toLocaleString("fr-FR")}</p>
</div> </div>
<div className="bg-gray-50 rounded-lg p-3">
<p className="text-xs text-gray-500 font-medium mb-1">Source import</p>
<p className="text-sm font-semibold">
{selectedLog.importSource === "email" ? (
<span className="inline-flex items-center gap-1 text-blue-700"><Mail className="h-4 w-4" /> Email</span>
) : selectedLog.importSource === "folder" ? (
<span className="inline-flex items-center gap-1 text-purple-700"><FolderOpen className="h-4 w-4" /> Dossier</span>
) : (
<span className="inline-flex items-center gap-1 text-gray-700"><Upload className="h-4 w-4" /> Fichier</span>
)}
</p>
</div>
</div> </div>
{/* Statistiques */} {/* Statistiques */}

View File

@@ -1,4 +1,4 @@
import React, { useState } from "react"; import React, { useState, useMemo } from "react";
import DashboardLayout from "@/components/DashboardLayout"; import DashboardLayout from "@/components/DashboardLayout";
import { Card, CardContent } from "@/components/ui/card"; import { Card, CardContent } from "@/components/ui/card";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
@@ -155,7 +155,12 @@ export default function InvoicesBAP() {
// Filter for BAP invoices only (Abonnement = NON, isSubscription = 0) // Filter for BAP invoices only (Abonnement = NON, isSubscription = 0)
const invoices = allInvoices?.filter(inv => inv.isSubscription === 0); const invoices = allInvoices?.filter(inv => inv.isSubscription === 0);
// IDs des factures déjà validées BAP (pour charger leurs pdfUrl depuis bapHistory) // IDs des factures déjà validées BAP (pour charger leurs pdfUrl depuis bapHistory)
const validatedInvoiceIds = (invoices || []).filter(inv => inv.bapValidated === 1).map(inv => inv.id); // Stabilisé avec useMemo pour éviter les re-renders infinis (anti-pattern tRPC)
const validatedInvoiceIds = useMemo(
() => (invoices || []).filter(inv => inv.bapValidated === 1).map(inv => inv.id),
// eslint-disable-next-line react-hooks/exhaustive-deps
[invoices?.map(i => i.id).join(',')]
);
const { data: persistedBapPdfUrls } = trpc.invoices.getBapPdfUrls.useQuery( const { data: persistedBapPdfUrls } = trpc.invoices.getBapPdfUrls.useQuery(
{ invoiceIds: validatedInvoiceIds }, { invoiceIds: validatedInvoiceIds },
{ enabled: validatedInvoiceIds.length > 0 } { enabled: validatedInvoiceIds.length > 0 }
@@ -284,6 +289,21 @@ export default function InvoicesBAP() {
}, },
}); });
const regenerateBapPdfMutation = trpc.invoices.regenerateBapPdf.useMutation({
onSuccess: (data, variables) => {
if (data.pdfUrl) {
setBapPdfUrls(prev => ({ ...prev, [variables.invoiceId]: data.pdfUrl as string }));
toast.success('PDF BAP régénéré avec succès !');
downloadBapPdf(data.pdfUrl);
}
utils.invoices.list.invalidate();
utils.invoices.getBapPdfUrls.invalidate();
},
onError: (error) => {
toast.error(error.message || 'Erreur lors de la régénération du PDF BAP');
},
});
const devalidateBAPMutation = trpc.invoices.devalidateBAP.useMutation({ const devalidateBAPMutation = trpc.invoices.devalidateBAP.useMutation({
onSuccess: (data) => { onSuccess: (data) => {
toast.success(`${data.processed} facture(s) dévalidée(s) BAP avec succès`); toast.success(`${data.processed} facture(s) dévalidée(s) BAP avec succès`);
@@ -995,11 +1015,14 @@ export default function InvoicesBAP() {
<Button <Button
size="sm" size="sm"
variant="outline" variant="outline"
className="h-8 px-2 text-gray-400 border-gray-200 cursor-not-allowed" className="h-8 px-2 text-orange-500 border-orange-300 hover:bg-orange-50"
title="PDF non disponible" title="PDF non disponible — cliquer pour régénérer"
disabled disabled={regenerateBapPdfMutation.isPending}
onClick={() => regenerateBapPdfMutation.mutate({ invoiceId: invoice.id })}
> >
<Download className="h-4 w-4" /> {regenerateBapPdfMutation.isPending && regenerateBapPdfMutation.variables?.invoiceId === invoice.id
? <RefreshCw className="h-4 w-4 animate-spin" />
: <RefreshCw className="h-4 w-4" />}
</Button> </Button>
)} )}
</div> </div>

View File

@@ -1,4 +1,4 @@
import { useState } from "react"; import { useState, useEffect } from "react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card"; import { Card, CardContent } from "@/components/ui/card";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
@@ -10,9 +10,33 @@ import { toast } from "sonner";
const ITINOVA_LOGO = "https://d2xsxph8kpxj0f.cloudfront.net/310519663070627318/fo4DRyBgjsuiigFAgNLuWm/itinova-logo_2a2ba00a.jpg"; const ITINOVA_LOGO = "https://d2xsxph8kpxj0f.cloudfront.net/310519663070627318/fo4DRyBgjsuiigFAgNLuWm/itinova-logo_2a2ba00a.jpg";
const SANTINOVA_LOGO = "https://d2xsxph8kpxj0f.cloudfront.net/310519663070627318/fo4DRyBgjsuiigFAgNLuWm/santinova-logo_5ae0d248.webp"; const SANTINOVA_LOGO = "https://d2xsxph8kpxj0f.cloudfront.net/310519663070627318/fo4DRyBgjsuiigFAgNLuWm/santinova-logo_5ae0d248.webp";
// Logo Microsoft SVG officiel
function MicrosoftLogo() {
return (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 21 21" width="18" height="18">
<rect x="1" y="1" width="9" height="9" fill="#f25022"/>
<rect x="11" y="1" width="9" height="9" fill="#7fba00"/>
<rect x="1" y="11" width="9" height="9" fill="#00a4ef"/>
<rect x="11" y="11" width="9" height="9" fill="#ffb900"/>
</svg>
);
}
export default function Login() { export default function Login() {
const [username, setUsername] = useState(""); const [username, setUsername] = useState("");
const [password, setPassword] = useState(""); const [password, setPassword] = useState("");
const [azureLoading, setAzureLoading] = useState(false);
// Afficher les erreurs transmises via query param (ex: depuis le callback Azure)
useEffect(() => {
const params = new URLSearchParams(window.location.search);
const error = params.get("error");
if (error) {
toast.error(decodeURIComponent(error));
// Nettoyer l'URL
window.history.replaceState({}, "", "/login");
}
}, []);
const loginMutation = trpc.auth.loginLocal.useMutation({ const loginMutation = trpc.auth.loginLocal.useMutation({
onSuccess: () => { onSuccess: () => {
@@ -24,11 +48,36 @@ export default function Login() {
}, },
}); });
const azureLoginQuery = trpc.auth.getAzureLoginUrl.useQuery(undefined, {
enabled: false,
retry: false,
});
const azureAvailableQuery = trpc.auth.isAzureAdAvailable.useQuery();
const handleLocalLogin = (e: React.FormEvent) => { const handleLocalLogin = (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
loginMutation.mutate({ email: username, password }); loginMutation.mutate({ email: username, password });
}; };
const handleMicrosoftLogin = async () => {
setAzureLoading(true);
try {
const result = await azureLoginQuery.refetch();
if (result.data?.url) {
window.location.href = result.data.url;
} else {
toast.error("Impossible d'obtenir l'URL de connexion Microsoft");
setAzureLoading(false);
}
} catch {
toast.error("Erreur lors de la connexion Microsoft");
setAzureLoading(false);
}
};
const azureAvailable = azureAvailableQuery.data?.available ?? false;
return ( return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-slate-50 to-blue-50 p-4"> <div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-slate-50 to-blue-50 p-4">
<div className="w-full max-w-sm flex flex-col items-center gap-6"> <div className="w-full max-w-sm flex flex-col items-center gap-6">
@@ -50,6 +99,35 @@ export default function Login() {
<p className="text-sm text-slate-500 mt-1">Connectez-vous pour accéder à l'application</p> <p className="text-sm text-slate-500 mt-1">Connectez-vous pour accéder à l'application</p>
</div> </div>
{/* Bouton Microsoft 365 */}
{azureAvailable && (
<>
<Button
type="button"
variant="outline"
className="w-full flex items-center gap-3 border-slate-300 bg-white hover:bg-slate-50 text-slate-700 font-medium mb-4"
onClick={handleMicrosoftLogin}
disabled={azureLoading}
>
{azureLoading ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<MicrosoftLogo />
)}
Se connecter avec Microsoft 365
</Button>
<div className="relative mb-4">
<div className="absolute inset-0 flex items-center">
<span className="w-full border-t border-slate-200" />
</div>
<div className="relative flex justify-center text-xs uppercase">
<span className="bg-white px-2 text-slate-400">ou</span>
</div>
</div>
</>
)}
<form onSubmit={handleLocalLogin} className="space-y-4"> <form onSubmit={handleLocalLogin} className="space-y-4">
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="username">Identifiant</Label> <Label htmlFor="username">Identifiant</Label>

View File

@@ -677,12 +677,14 @@ export default function Settings() {
const [sftpAutoExport, setSftpAutoExport] = useState(false); const [sftpAutoExport, setSftpAutoExport] = useState(false);
const [sftpRecipientFilter, setSftpRecipientFilter] = useState(""); const [sftpRecipientFilter, setSftpRecipientFilter] = useState("");
const [llmLogsRetentionMonths, setLlmLogsRetentionMonths] = useState(3); const [llmLogsRetentionMonths, setLlmLogsRetentionMonths] = useState(3);
const [aiProvider, setAiProvider] = useState<"mistral" | "manus">("mistral"); const [aiProvider, setAiProvider] = useState<"mistral" | "manus" | "gemini">("mistral");
const [mistralApiKey, setMistralApiKey] = useState(""); const [mistralApiKey, setMistralApiKey] = useState("");
const [manusForgeApiKey, setManusForgeApiKey] = useState(""); const [manusForgeApiKey, setManusForgeApiKey] = useState("");
const [manusForgeApiUrl, setManusForgeApiUrl] = useState(""); const [manusForgeApiUrl, setManusForgeApiUrl] = useState("");
const [geminiApiKey, setGeminiApiKey] = useState("");
const [showMistralKey, setShowMistralKey] = useState(false); const [showMistralKey, setShowMistralKey] = useState(false);
const [showManusKey, setShowManusKey] = useState(false); const [showManusKey, setShowManusKey] = useState(false);
const [showGeminiKey, setShowGeminiKey] = useState(false);
useEffect(() => { useEffect(() => {
if (settings) { if (settings) {
@@ -706,6 +708,7 @@ export default function Settings() {
setMistralApiKey((settings as any).mistralApiKey || ""); setMistralApiKey((settings as any).mistralApiKey || "");
setManusForgeApiKey((settings as any).manusForgeApiKey || ""); setManusForgeApiKey((settings as any).manusForgeApiKey || "");
setManusForgeApiUrl((settings as any).manusForgeApiUrl || ""); setManusForgeApiUrl((settings as any).manusForgeApiUrl || "");
setGeminiApiKey((settings as any).geminiApiKey || "");
} }
}, [settings]); }, [settings]);
@@ -754,6 +757,7 @@ export default function Settings() {
mistralApiKey, mistralApiKey,
manusForgeApiKey, manusForgeApiKey,
manusForgeApiUrl, manusForgeApiUrl,
geminiApiKey,
}); });
}; };
@@ -853,7 +857,7 @@ export default function Settings() {
<CardContent className="space-y-6 pt-6"> <CardContent className="space-y-6 pt-6">
{/* Provider selector */} {/* Provider selector */}
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-3 gap-4">
<button <button
type="button" type="button"
onClick={() => setAiProvider("mistral")} onClick={() => setAiProvider("mistral")}
@@ -880,6 +884,36 @@ export default function Settings() {
)} )}
</button> </button>
{/* Google Gemini */}
<button
type="button"
onClick={() => setAiProvider("gemini")}
className={`relative flex flex-col items-center gap-3 p-5 rounded-xl border-2 transition-all cursor-pointer ${
aiProvider === "gemini"
? "border-green-500 bg-green-50 dark:bg-green-950/20 shadow-md"
: "border-muted hover:border-green-300 bg-background"
}`}
>
{aiProvider === "gemini" && (
<div className="absolute top-2 right-2">
<CheckCircle className="w-5 h-5 text-green-500" />
</div>
)}
<div className="p-3 bg-green-100 dark:bg-green-900/30 rounded-xl">
<svg className="w-8 h-8" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2z" fill="#4285F4"/>
<path d="M12 6l1.5 4.5H18l-3.75 2.75L15.75 18 12 15.25 8.25 18l1.5-4.75L6 10.5h4.5L12 6z" fill="white"/>
</svg>
</div>
<div className="text-center">
<p className="font-bold text-base">Google Gemini</p>
<p className="text-xs text-muted-foreground mt-1">Gemini 2.0 Flash<br/>Gratuit (1500 req/j)</p>
</div>
{aiProvider === "gemini" && (
<Badge className="bg-green-500 text-white text-xs">Actif</Badge>
)}
</button>
<button <button
type="button" type="button"
onClick={() => setAiProvider("manus")} onClick={() => setAiProvider("manus")}
@@ -938,6 +972,46 @@ export default function Settings() {
</div> </div>
)} )}
{/* Gemini config */}
{aiProvider === "gemini" && (
<div className="space-y-4 p-4 bg-green-50 dark:bg-green-950/10 rounded-xl border border-green-200 dark:border-green-800">
<div className="flex items-center justify-between">
<p className="text-sm font-semibold text-green-700 dark:text-green-400 uppercase tracking-wide">Configuration Google Gemini</p>
<Badge className="bg-green-100 text-green-700 border border-green-300 text-xs font-normal">Gratuit — 1 500 req/jour</Badge>
</div>
<div className="space-y-2">
<Label htmlFor="geminiApiKey" className="font-medium">Clé API Google AI Studio</Label>
<div className="relative">
<Input
id="geminiApiKey"
type={showGeminiKey ? "text" : "password"}
value={geminiApiKey}
onChange={(e) => setGeminiApiKey(e.target.value)}
placeholder="Votre clé API Google (ex: AIzaSy...)"
className="h-11 pr-10"
/>
<button
type="button"
onClick={() => setShowGeminiKey(!showGeminiKey)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
>
{showGeminiKey ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
</button>
</div>
<p className="text-xs text-muted-foreground flex items-center gap-1">
<AlertCircle className="w-3 h-3" />
Obtenez votre clé gratuite sur{" "}
<a href="https://aistudio.google.com/apikey" target="_blank" rel="noopener noreferrer" className="text-green-600 hover:underline">aistudio.google.com/apikey</a>.
Laissez vide pour utiliser la variable d'environnement GEMINI_API_KEY du serveur.
</p>
</div>
<div className="p-3 bg-green-100 dark:bg-green-900/20 rounded-lg border border-green-200 dark:border-green-800">
<p className="text-xs text-green-800 dark:text-green-300 font-medium">Modèle utilisé : <span className="font-bold">gemini-2.0-flash</span></p>
<p className="text-xs text-green-700 dark:text-green-400 mt-1">Analyse directe des PDF, extraction structurée JSON, compatible avec l'interface OpenAI.</p>
</div>
</div>
)}
{/* Manus config */} {/* Manus config */}
{aiProvider === "manus" && ( {aiProvider === "manus" && (
<div className="space-y-4 p-4 bg-blue-50 dark:bg-blue-950/10 rounded-xl border border-blue-200 dark:border-blue-800"> <div className="space-y-4 p-4 bg-blue-50 dark:bg-blue-950/10 rounded-xl border border-blue-200 dark:border-blue-800">
@@ -993,14 +1067,14 @@ export default function Settings() {
<div> <div>
<CardTitle className="text-xl">Configuration du modèle AI</CardTitle> <CardTitle className="text-xl">Configuration du modèle AI</CardTitle>
<CardDescription className="mt-1"> <CardDescription className="mt-1">
Paramètres du modèle d'extraction Mistral AI Paramètres avancés du modèle d'extraction IA
</CardDescription> </CardDescription>
</div> </div>
</div> </div>
</CardHeader> </CardHeader>
<CardContent className="space-y-6 pt-6"> <CardContent className="space-y-6 pt-6">
<div className="space-y-3"> <div className="space-y-3">
<Label htmlFor="llmModel" className="text-base font-semibold">Modèle Mistral</Label> <Label htmlFor="llmModel" className="text-base font-semibold">Modèle IA (override)</Label>
<Input <Input
id="llmModel" id="llmModel"
value={llmModel} value={llmModel}

View File

@@ -0,0 +1 @@
ALTER TABLE `importLogs` ADD `importSource` enum('file','folder','email') DEFAULT 'file' NOT NULL;

View File

@@ -0,0 +1 @@
ALTER TABLE `users` MODIFY COLUMN `azureAdId` varchar(128);

View File

@@ -0,0 +1,4 @@
ALTER TABLE `importSettings` MODIFY COLUMN `aiProvider` enum('mistral','manus','gemini') NOT NULL DEFAULT 'mistral';--> statement-breakpoint
ALTER TABLE `userSettings` MODIFY COLUMN `aiProvider` enum('mistral','manus','gemini') NOT NULL DEFAULT 'mistral';--> statement-breakpoint
ALTER TABLE `importSettings` ADD `geminiApiKey` text;--> statement-breakpoint
ALTER TABLE `userSettings` ADD `geminiApiKey` text;

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -225,6 +225,27 @@
"when": 1781021650065, "when": 1781021650065,
"tag": "0031_mean_squadron_supreme", "tag": "0031_mean_squadron_supreme",
"breakpoints": true "breakpoints": true
},
{
"idx": 32,
"version": "5",
"when": 1781690960721,
"tag": "0032_volatile_jocasta",
"breakpoints": true
},
{
"idx": 33,
"version": "5",
"when": 1783419609559,
"tag": "0033_strange_manta",
"breakpoints": true
},
{
"idx": 34,
"version": "5",
"when": 1785167359371,
"tag": "0034_black_shadowcat",
"breakpoints": true
} }
] ]
} }

View File

@@ -9,7 +9,7 @@ export const users = mysqlTable("users", {
/** Manus OAuth identifier (openId) - Optional for backward compatibility */ /** Manus OAuth identifier (openId) - Optional for backward compatibility */
openId: varchar("openId", { length: 64 }).unique(), openId: varchar("openId", { length: 64 }).unique(),
/** Azure AD Object ID - Unique identifier from Azure AD */ /** Azure AD Object ID - Unique identifier from Azure AD */
azureAdId: varchar("azureAdId", { length: 64 }).unique(), azureAdId: varchar("azureAdId", { length: 128 }).unique(),
name: text("name"), name: text("name"),
email: varchar("email", { length: 320 }).notNull().unique(), email: varchar("email", { length: 320 }).notNull().unique(),
/** Hashed password for local authentication (bcrypt) */ /** Hashed password for local authentication (bcrypt) */
@@ -149,10 +149,11 @@ export const userSettings = mysqlTable("userSettings", {
// Seuil de confiance pour les apprentissages IA // Seuil de confiance pour les apprentissages IA
learningConfidenceThreshold: int("learningConfidenceThreshold").default(2).notNull(), // Nombre minimum d'applications pour marquer un apprentissage comme Confirmé learningConfidenceThreshold: int("learningConfidenceThreshold").default(2).notNull(), // Nombre minimum d'applications pour marquer un apprentissage comme Confirmé
// AI Engine configuration // AI Engine configuration
aiProvider: mysqlEnum("aiProvider", ["mistral", "manus"]).default("mistral").notNull(), // AI provider: 'mistral' or 'manus' aiProvider: mysqlEnum("aiProvider", ["mistral", "manus", "gemini"]).default("mistral").notNull(), // AI provider: 'mistral', 'manus' or 'gemini'
mistralApiKey: text("mistralApiKey"), // Mistral API key (overrides env MISTRAL_API_KEY) mistralApiKey: text("mistralApiKey"), // Mistral API key (overrides env MISTRAL_API_KEY)
manusForgeApiKey: text("manusForgeApiKey"), // Manus Forge API key (overrides env BUILT_IN_FORGE_API_KEY) manusForgeApiKey: text("manusForgeApiKey"), // Manus Forge API key (overrides env BUILT_IN_FORGE_API_KEY)
manusForgeApiUrl: text("manusForgeApiUrl"), // Manus Forge API URL (overrides env BUILT_IN_FORGE_API_URL) manusForgeApiUrl: text("manusForgeApiUrl"), // Manus Forge API URL (overrides env BUILT_IN_FORGE_API_URL)
geminiApiKey: text("geminiApiKey"), // Google Gemini API key (overrides env GEMINI_API_KEY)
createdAt: timestamp("createdAt").defaultNow().notNull(), createdAt: timestamp("createdAt").defaultNow().notNull(),
updatedAt: timestamp("updatedAt").defaultNow().onUpdateNow().notNull(), updatedAt: timestamp("updatedAt").defaultNow().onUpdateNow().notNull(),
}); });
@@ -196,10 +197,11 @@ export const importSettings = mysqlTable("importSettings", {
azureSecretExpiresAt: timestamp("azureSecretExpiresAt"), // Azure AD Client Secret expiration date azureSecretExpiresAt: timestamp("azureSecretExpiresAt"), // Azure AD Client Secret expiration date
// AI Engine settings // AI Engine settings
aiProvider: mysqlEnum("aiProvider", ["mistral", "manus"]).default("mistral").notNull(), // AI provider for invoice extraction aiProvider: mysqlEnum("aiProvider", ["mistral", "manus", "gemini"]).default("mistral").notNull(), // AI provider for invoice extraction
mistralApiKey: text("mistralApiKey"), // Mistral API key (overrides env MISTRAL_API_KEY) mistralApiKey: text("mistralApiKey"), // Mistral API key (overrides env MISTRAL_API_KEY)
manusForgeApiKey: text("manusForgeApiKey"), // Manus Forge API key (overrides env BUILT_IN_FORGE_API_KEY) manusForgeApiKey: text("manusForgeApiKey"), // Manus Forge API key (overrides env BUILT_IN_FORGE_API_KEY)
manusForgeApiUrl: text("manusForgeApiUrl"), // Manus Forge API URL (overrides env BUILT_IN_FORGE_API_URL) manusForgeApiUrl: text("manusForgeApiUrl"), // Manus Forge API URL (overrides env BUILT_IN_FORGE_API_URL)
geminiApiKey: text("geminiApiKey"), // Google Gemini API key (overrides env GEMINI_API_KEY)
createdAt: timestamp("createdAt").defaultNow().notNull(), createdAt: timestamp("createdAt").defaultNow().notNull(),
updatedAt: timestamp("updatedAt").defaultNow().onUpdateNow().notNull(), updatedAt: timestamp("updatedAt").defaultNow().onUpdateNow().notNull(),
@@ -223,6 +225,8 @@ export const importLogs = mysqlTable("importLogs", {
duplicateDetails: text("duplicateDetails"), // JSON array of duplicate invoice info duplicateDetails: text("duplicateDetails"), // JSON array of duplicate invoice info
errorDetails: text("errorDetails"), // JSON array of error messages errorDetails: text("errorDetails"), // JSON array of error messages
warningMessage: text("warningMessage"), // Warning message (e.g. quota exhausted) warningMessage: text("warningMessage"), // Warning message (e.g. quota exhausted)
/** Source du mode d'import : 'file' = upload manuel, 'folder' = dossier automatique, 'email' = import par email */
importSource: mysqlEnum("importSource", ["file", "folder", "email"]).default("file").notNull(),
importedAt: timestamp("importedAt").defaultNow().notNull(), importedAt: timestamp("importedAt").defaultNow().notNull(),
}); });

View File

@@ -8,4 +8,5 @@ export const ENV = {
forgeApiUrl: process.env.BUILT_IN_FORGE_API_URL ?? "", forgeApiUrl: process.env.BUILT_IN_FORGE_API_URL ?? "",
forgeApiKey: process.env.BUILT_IN_FORGE_API_KEY ?? "", forgeApiKey: process.env.BUILT_IN_FORGE_API_KEY ?? "",
mistralApiKey: process.env.MISTRAL_API_KEY ?? "", mistralApiKey: process.env.MISTRAL_API_KEY ?? "",
geminiApiKey: process.env.GEMINI_API_KEY ?? "",
}; };

View File

@@ -10,10 +10,11 @@ import { registerOAuthRoutes } from "./oauth";
import { appRouter } from "../routers"; import { appRouter } from "../routers";
import { createContext } from "./context"; import { createContext } from "./context";
import { serveStatic, setupVite } from "./vite"; import { serveStatic, setupVite } from "./vite";
import { getAllUsers } from "../db"; import { getAllUsers, getUserByAzureAdId, getUserByEmail, upsertUser } from "../db";
import { startEmailImportService } from "../emailImportService"; import { startEmailImportService } from "../emailImportService";
import { startFolderImportService } from "../folderImportService"; import { startFolderImportService } from "../folderImportService";
import { getImportSettingsByUser } from "../db"; import { getImportSettingsByUser } from "../db";
import { handleAzureCallback, isAzureAdConfigured, generateToken } from "../auth";
function isPortAvailable(port: number): Promise<boolean> { function isPortAvailable(port: number): Promise<boolean> {
return new Promise(resolve => { return new Promise(resolve => {
@@ -143,6 +144,86 @@ async function startServer() {
archive.finalize(); archive.finalize();
}); });
// ============= AZURE AD OAUTH2 CALLBACK =============
app.get("/api/auth/azure/callback", async (req, res) => {
const code = req.query.code as string | undefined;
const error = req.query.error as string | undefined;
if (error) {
console.error("[Azure AD] Erreur OAuth:", error, req.query.error_description);
res.redirect(`/login?error=${encodeURIComponent("Connexion Microsoft refusée")}`);
return;
}
if (!code) {
res.redirect("/login?error=" + encodeURIComponent("Code OAuth manquant"));
return;
}
if (!isAzureAdConfigured()) {
res.redirect("/login?error=" + encodeURIComponent("Azure AD non configuré"));
return;
}
try {
const azureUser = await handleAzureCallback(code);
// Chercher l'utilisateur par azureAdId ou par email
let user = await getUserByAzureAdId(azureUser.azureAdId);
if (!user) {
user = await getUserByEmail(azureUser.email);
}
if (!user) {
// Créer l'utilisateur automatiquement
await upsertUser({
email: azureUser.email,
name: azureUser.name,
azureAdId: azureUser.azureAdId,
loginMethod: "azure-ad",
isActive: 1,
role: "user",
});
user = await getUserByEmail(azureUser.email);
} else {
// Mettre à jour l'azureAdId si manquant
if (!user.azureAdId) {
await upsertUser({
email: user.email,
azureAdId: azureUser.azureAdId,
loginMethod: user.loginMethod,
});
}
}
if (!user) {
res.redirect("/login?error=" + encodeURIComponent("Impossible de créer le compte"));
return;
}
if (user.isActive === 0) {
res.redirect("/login?error=" + encodeURIComponent("Compte inactif"));
return;
}
// Générer le token JWT et poser le cookie
const token = generateToken(user);
res.cookie("auth_token", token, {
httpOnly: true,
secure: false,
sameSite: "lax",
path: "/",
maxAge: 7 * 24 * 60 * 60 * 1000,
});
console.log(`[Azure AD] Connexion réussie pour ${user.email}`);
res.redirect("/");
} catch (err: any) {
console.error("[Azure AD] Erreur callback:", err.message);
res.redirect("/login?error=" + encodeURIComponent("Erreur d'authentification Microsoft"));
}
});
// tRPC API // tRPC API
app.use( app.use(
"/api/trpc", "/api/trpc",

View File

@@ -283,31 +283,45 @@ const normalizeResponseFormat = ({
/** /**
* Résout l'URL API en tenant compte des paramètres utilisateur (DB) en priorité sur les variables d'environnement. * Résout l'URL API en tenant compte des paramètres utilisateur (DB) en priorité sur les variables d'environnement.
*/ */
function resolveApiUrlWithSettings(settings?: { aiProvider?: string | null; mistralApiKey?: string | null; manusForgeApiKey?: string | null; manusForgeApiUrl?: string | null }): string { function resolveApiUrlWithSettings(settings?: { aiProvider?: string | null; mistralApiKey?: string | null; manusForgeApiKey?: string | null; manusForgeApiUrl?: string | null; geminiApiKey?: string | null }): string {
const provider = settings?.aiProvider || "mistral"; const provider = settings?.aiProvider || "mistral";
if (provider === "mistral") { if (provider === "mistral") {
return "https://api.mistral.ai/v1/chat/completions"; return "https://api.mistral.ai/v1/chat/completions";
} }
if (provider === "gemini") {
// Google AI Studio — interface compatible OpenAI
return "https://generativelanguage.googleapis.com/v1beta/openai/chat/completions";
}
// Manus provider // Manus provider
const forgeUrl = settings?.manusForgeApiUrl || ENV.forgeApiUrl || "https://forge.manus.ai"; const forgeUrl = settings?.manusForgeApiUrl || ENV.forgeApiUrl || "https://forge.manus.ai";
return `${forgeUrl.replace(/\/$/, "")}/v1/chat/completions`; return `${forgeUrl.replace(/\/$/, "")}/v1/chat/completions`;
} }
function getApiKeyWithSettings(settings?: { aiProvider?: string | null; mistralApiKey?: string | null; manusForgeApiKey?: string | null }): string { function getApiKeyWithSettings(settings?: { aiProvider?: string | null; mistralApiKey?: string | null; manusForgeApiKey?: string | null; geminiApiKey?: string | null }): string {
const provider = settings?.aiProvider || "mistral"; const provider = settings?.aiProvider || "mistral";
if (provider === "mistral") { if (provider === "mistral") {
return settings?.mistralApiKey || ENV.mistralApiKey || ""; return settings?.mistralApiKey || ENV.mistralApiKey || "";
} }
if (provider === "gemini") {
return settings?.geminiApiKey || ENV.geminiApiKey || "";
}
return settings?.manusForgeApiKey || ENV.forgeApiKey || ""; return settings?.manusForgeApiKey || ENV.forgeApiKey || "";
} }
function getModelForProvider(provider: string): string {
if (provider === "mistral") return "mistral-large-latest";
if (provider === "gemini") return "gemini-2.0-flash";
// Manus Forge → Gemini 2.5 Flash via proxy
return "gemini-2.5-flash";
}
/** /**
* Version de invokeLLM qui accepte les paramètres utilisateur depuis la DB * Version de invokeLLM qui accepte les paramètres utilisateur depuis la DB
* pour choisir dynamiquement le moteur IA (Mistral ou Manus). * pour choisir dynamiquement le moteur IA (Mistral, Manus ou Gemini).
*/ */
export async function invokeLLMWithUserSettings( export async function invokeLLMWithUserSettings(
params: InvokeParams, params: InvokeParams,
userSettings?: { aiProvider?: string | null; mistralApiKey?: string | null; manusForgeApiKey?: string | null; manusForgeApiUrl?: string | null } userSettings?: { aiProvider?: string | null; mistralApiKey?: string | null; manusForgeApiKey?: string | null; manusForgeApiUrl?: string | null; geminiApiKey?: string | null }
): Promise<InvokeResult> { ): Promise<InvokeResult> {
const apiKey = getApiKeyWithSettings(userSettings); const apiKey = getApiKeyWithSettings(userSettings);
if (!apiKey || apiKey.trim().length === 0) { if (!apiKey || apiKey.trim().length === 0) {
@@ -328,7 +342,7 @@ export async function invokeLLMWithUserSettings(
response_format, response_format,
} = params; } = params;
const model = isMistral ? "mistral-large-latest" : "gemini-2.5-flash"; const model = getModelForProvider(provider);
const payload: Record<string, unknown> = { const payload: Record<string, unknown> = {
model, model,
@@ -342,7 +356,8 @@ export async function invokeLLMWithUserSettings(
payload.max_tokens = 32768; payload.max_tokens = 32768;
if (!isMistral) { // thinking uniquement pour Manus Forge (proxy Gemini)
if (provider === "manus") {
payload.thinking = { budget_tokens: 128 }; payload.thinking = { budget_tokens: 128 };
} }

View File

@@ -268,11 +268,17 @@ export async function deleteInvoice(id: number) {
await db.delete(invoices).where(eq(invoices.id, id)); await db.delete(invoices).where(eq(invoices.id, id));
} }
export async function searchInvoices(userId: number, query: string): Promise<Invoice[]> { export async function searchInvoices(userId: number | null, query: string): Promise<Invoice[]> {
const db = await getDb(); const db = await getDb();
if (!db) return []; if (!db) return [];
const searchPattern = `%${query}%`; const searchPattern = `%${query}%`;
if (userId === null) {
// Admin : recherche globale sans filtre userId
return db.select().from(invoices)
.where(sql`(${invoices.supplierName} LIKE ${searchPattern} OR ${invoices.invoiceNumber} LIKE ${searchPattern})`)
.orderBy(desc(invoices.createdAt));
}
return db.select().from(invoices) return db.select().from(invoices)
.where( .where(
and( and(
@@ -485,10 +491,15 @@ export async function getAllImportLogs(): Promise<ImportLog[]> {
return db.select().from(importLogs).orderBy(desc(importLogs.importedAt)); return db.select().from(importLogs).orderBy(desc(importLogs.importedAt));
} }
export async function deleteAllImportLogs(userId: number): Promise<void> { export async function deleteAllImportLogs(userId: number | null): Promise<void> {
const db = await getDb(); const db = await getDb();
if (!db) throw new Error("Database not available"); if (!db) throw new Error("Database not available");
await db.delete(importLogs).where(eq(importLogs.userId, userId)); if (userId === null) {
// Admin : supprime tous les logs
await db.delete(importLogs);
} else {
await db.delete(importLogs).where(eq(importLogs.userId, userId));
}
} }
// ============= LLM LOG OPERATIONS ============= // ============= LLM LOG OPERATIONS =============
@@ -555,10 +566,10 @@ export async function upsertImportSettings(data: InsertImportSettings): Promise<
// ============= DEPARTMENT LIST OPERATIONS ============= // ============= DEPARTMENT LIST OPERATIONS =============
export async function getDepartmentsByUser(userId: number): Promise<Department[]> { export async function getDepartmentsByUser(_userId?: number): Promise<Department[]> {
const db = await getDb(); const db = await getDb();
if (!db) return []; if (!db) return [];
return await db.select().from(departmentList).where(eq(departmentList.userId, userId)).orderBy(departmentList.name); return await db.select().from(departmentList).orderBy(departmentList.name);
} }
export async function createDepartment(data: InsertDepartment): Promise<Department> { export async function createDepartment(data: InsertDepartment): Promise<Department> {
@@ -576,10 +587,10 @@ export async function deleteDepartment(id: number): Promise<void> {
// ============= ACCOUNTING ALLOCATION LIST OPERATIONS ============= // ============= ACCOUNTING ALLOCATION LIST OPERATIONS =============
export async function getAccountingAllocationsByUser(userId: number): Promise<AccountingAllocation[]> { export async function getAccountingAllocationsByUser(_userId?: number): Promise<AccountingAllocation[]> {
const db = await getDb(); const db = await getDb();
if (!db) return []; if (!db) return [];
return await db.select().from(accountingAllocationList).where(eq(accountingAllocationList.userId, userId)).orderBy(accountingAllocationList.name); return await db.select().from(accountingAllocationList).orderBy(accountingAllocationList.name);
} }
export async function createAccountingAllocation(data: InsertAccountingAllocation): Promise<AccountingAllocation> { export async function createAccountingAllocation(data: InsertAccountingAllocation): Promise<AccountingAllocation> {
@@ -731,10 +742,10 @@ export async function initializeDefaultLlmFields(userId: number): Promise<void>
// ============= SIGNATURES HELPERS ============= // ============= SIGNATURES HELPERS =============
export async function getSignaturesByUser(userId: number): Promise<Signature[]> { export async function getSignaturesByUser(_userId?: number): Promise<Signature[]> {
const db = await getDb(); const db = await getDb();
if (!db) return []; if (!db) return [];
return db.select().from(signatures).where(eq(signatures.userId, userId)); return db.select().from(signatures);
} }
export async function getSignatureById(id: number): Promise<Signature | undefined> { export async function getSignatureById(id: number): Promise<Signature | undefined> {
@@ -762,10 +773,10 @@ export async function deleteSignature(id: number): Promise<void> {
// ============= SERVICE SIGNATURES HELPERS ============= // ============= SERVICE SIGNATURES HELPERS =============
export async function getServiceSignaturesByUser(userId: number): Promise<ServiceSignature[]> { export async function getServiceSignaturesByUser(_userId?: number): Promise<ServiceSignature[]> {
const db = await getDb(); const db = await getDb();
if (!db) return []; if (!db) return [];
return db.select().from(serviceSignatures).where(eq(serviceSignatures.userId, userId)); return db.select().from(serviceSignatures);
} }
export async function getServiceSignatureByService(userId: number, serviceName: string): Promise<ServiceSignature | undefined> { export async function getServiceSignatureByService(userId: number, serviceName: string): Promise<ServiceSignature | undefined> {

View File

@@ -92,6 +92,7 @@ async function processEmailAttachment(
mistralApiKey: settings.mistralApiKey, mistralApiKey: settings.mistralApiKey,
manusForgeApiKey: settings.manusForgeApiKey, manusForgeApiKey: settings.manusForgeApiKey,
manusForgeApiUrl: settings.manusForgeApiUrl, manusForgeApiUrl: settings.manusForgeApiUrl,
geminiApiKey: (settings as any).geminiApiKey || null,
} : undefined; } : undefined;
// Extract invoices // Extract invoices
@@ -232,6 +233,7 @@ async function processEmailAttachment(
duplicateDetails: duplicateDetails.length > 0 ? JSON.stringify(duplicateDetails) : null, duplicateDetails: duplicateDetails.length > 0 ? JSON.stringify(duplicateDetails) : null,
errorDetails: errorDetails.length > 0 ? JSON.stringify(errorDetails) : null, errorDetails: errorDetails.length > 0 ? JSON.stringify(errorDetails) : null,
warningMessage, warningMessage,
importSource: "email",
}); });
console.log(`[EmailImport] Successfully processed attachment: ${fileName}`); console.log(`[EmailImport] Successfully processed attachment: ${fileName}`);

View File

@@ -191,6 +191,7 @@ async function processFolderFile(
errors: errorsCount, errors: errorsCount,
duplicateDetails: duplicateDetails.length > 0 ? JSON.stringify(duplicateDetails) : null, duplicateDetails: duplicateDetails.length > 0 ? JSON.stringify(duplicateDetails) : null,
errorDetails: errorDetails.length > 0 ? JSON.stringify(errorDetails) : null, errorDetails: errorDetails.length > 0 ? JSON.stringify(errorDetails) : null,
importSource: "folder",
}); });
// Move file to processed folder // Move file to processed folder

View File

@@ -218,6 +218,7 @@ export const appRouter = router({
mistralApiKey: (settings as any).mistralApiKey || null, mistralApiKey: (settings as any).mistralApiKey || null,
manusForgeApiKey: (settings as any).manusForgeApiKey || null, manusForgeApiKey: (settings as any).manusForgeApiKey || null,
manusForgeApiUrl: (settings as any).manusForgeApiUrl || null, manusForgeApiUrl: (settings as any).manusForgeApiUrl || null,
geminiApiKey: (settings as any).geminiApiKey || null,
} : undefined; } : undefined;
// Extract invoices // Extract invoices
@@ -370,6 +371,7 @@ export const appRouter = router({
errors: errorsCount, errors: errorsCount,
duplicateDetails: JSON.stringify(duplicateDetails), duplicateDetails: JSON.stringify(duplicateDetails),
errorDetails: JSON.stringify(errorDetails), errorDetails: JSON.stringify(errorDetails),
importSource: "file",
}); });
} catch (error: any) { } catch (error: any) {
@@ -398,7 +400,7 @@ export const appRouter = router({
.input(z.object({ id: z.number() })) .input(z.object({ id: z.number() }))
.query(async ({ input, ctx }) => { .query(async ({ input, ctx }) => {
const invoice = await getInvoiceById(input.id); const invoice = await getInvoiceById(input.id);
if (!invoice || invoice.userId !== ctx.user.id) { if (!invoice || (ctx.user.role !== 'admin' && invoice.userId !== ctx.user.id)) {
throw new TRPCError({ code: "NOT_FOUND" }); throw new TRPCError({ code: "NOT_FOUND" });
} }
return invoice; return invoice;
@@ -424,7 +426,7 @@ export const appRouter = router({
})) }))
.mutation(async ({ input, ctx }) => { .mutation(async ({ input, ctx }) => {
const invoice = await getInvoiceById(input.id); const invoice = await getInvoiceById(input.id);
if (!invoice || invoice.userId !== ctx.user.id) { if (!invoice || (ctx.user.role !== 'admin' && invoice.userId !== ctx.user.id)) {
throw new TRPCError({ code: "NOT_FOUND" }); throw new TRPCError({ code: "NOT_FOUND" });
} }
@@ -440,7 +442,7 @@ export const appRouter = router({
.input(z.object({ id: z.number() })) .input(z.object({ id: z.number() }))
.mutation(async ({ input, ctx }) => { .mutation(async ({ input, ctx }) => {
const invoice = await getInvoiceById(input.id); const invoice = await getInvoiceById(input.id);
if (!invoice || invoice.userId !== ctx.user.id) { if (!invoice || (ctx.user.role !== 'admin' && invoice.userId !== ctx.user.id)) {
throw new TRPCError({ code: "NOT_FOUND" }); throw new TRPCError({ code: "NOT_FOUND" });
} }
@@ -452,7 +454,7 @@ export const appRouter = router({
.input(z.object({ id: z.number() })) .input(z.object({ id: z.number() }))
.mutation(async ({ input, ctx }) => { .mutation(async ({ input, ctx }) => {
const invoice = await getInvoiceById(input.id); const invoice = await getInvoiceById(input.id);
if (!invoice || invoice.userId !== ctx.user.id) { if (!invoice || (ctx.user.role !== 'admin' && invoice.userId !== ctx.user.id)) {
throw new TRPCError({ code: "NOT_FOUND" }); throw new TRPCError({ code: "NOT_FOUND" });
} }
// Vérifier les critères de validation BAP // Vérifier les critères de validation BAP
@@ -616,9 +618,11 @@ export const appRouter = router({
// ── Mise à jour de la facture ───────────────────────────────────── // ── Mise à jour de la facture ─────────────────────────────────────
const validatedAt = new Date(); const validatedAt = new Date();
const newExportStatus = (exportPath || pdfUrl) ? 'exported' : 'not_exported';
await updateInvoice(input.id, { await updateInvoice(input.id, {
bapValidated: 1, bapValidated: 1,
bapValidatedAt: validatedAt, bapValidatedAt: validatedAt,
exportStatus: newExportStatus as any,
}); });
// ── Enregistrement dans l'historique BAP ───────────────────────── // ── Enregistrement dans l'historique BAP ─────────────────────────
@@ -663,7 +667,7 @@ export const appRouter = router({
let processed = 0; let processed = 0;
for (const id of input.invoiceIds) { for (const id of input.invoiceIds) {
const invoice = await getInvoiceById(id); const invoice = await getInvoiceById(id);
if (!invoice || invoice.userId !== ctx.user.id) continue; if (!invoice || (ctx.user.role !== 'admin' && invoice.userId !== ctx.user.id)) continue;
await updateInvoice(id, { await updateInvoice(id, {
bapValidated: 0, bapValidated: 0,
bapValidatedAt: null, bapValidatedAt: null,
@@ -677,7 +681,7 @@ export const appRouter = router({
// ── Validation BAP en masse ────────────────────────────── // ── Validation BAP en masse ──────────────────────────────
validateBAPBulk: protectedProcedure validateBAPBulk: protectedProcedure
.mutation(async ({ ctx }) => { .mutation(async ({ ctx }) => {
const allInvoices = await getInvoicesByUser(ctx.user.id); const allInvoices = ctx.user.role === 'admin' ? await getAllInvoices() : await getInvoicesByUser(ctx.user.id);
// Filtrer les factures éligibles (non déjà validées) // Filtrer les factures éligibles (non déjà validées)
const eligible = allInvoices.filter((inv: any) => const eligible = allInvoices.filter((inv: any) =>
(inv.qualityScore || 0) >= 100 && (inv.qualityScore || 0) >= 100 &&
@@ -816,7 +820,8 @@ export const appRouter = router({
console.warn(`[BAP Bulk] Erreur PDF facture ${invoice.id}:`, pdfError.message); console.warn(`[BAP Bulk] Erreur PDF facture ${invoice.id}:`, pdfError.message);
} }
// Mise à jour de la facture // Mise à jour de la facture
await updateInvoice(invoice.id, { bapValidated: 1, bapValidatedAt: validatedAt }); const bulkExportStatus = (exportPath || pdfUrl) ? 'exported' : 'not_exported';
await updateInvoice(invoice.id, { bapValidated: 1, bapValidatedAt: validatedAt, exportStatus: bulkExportStatus as any });
// Historique // Historique
await createBapHistoryEntry({ await createBapHistoryEntry({
userId: ctx.user.id, userId: ctx.user.id,
@@ -858,7 +863,7 @@ export const appRouter = router({
.mutation(async ({ input, ctx }) => { .mutation(async ({ input, ctx }) => {
// Relance UNIQUEMENT les automatismes (sans re-extraction LLM) // Relance UNIQUEMENT les automatismes (sans re-extraction LLM)
const { applyAutomationRules } = await import("./automationEngine"); const { applyAutomationRules } = await import("./automationEngine");
const allInvoices = await getInvoicesByUser(ctx.user.id); const allInvoices = ctx.user.role === 'admin' ? await getAllInvoices() : await getInvoicesByUser(ctx.user.id);
const selected = allInvoices.filter(inv => input.invoiceIds.includes(inv.id)); const selected = allInvoices.filter(inv => input.invoiceIds.includes(inv.id));
if (selected.length === 0) throw new TRPCError({ code: 'NOT_FOUND', message: 'Aucune facture trouvée' }); if (selected.length === 0) throw new TRPCError({ code: 'NOT_FOUND', message: 'Aucune facture trouvée' });
@@ -884,10 +889,121 @@ export const appRouter = router({
return { success: true, processed, errors, results }; return { success: true, processed, errors, results };
}), }),
// ── Régénérer le PDF BAP pour une facture validée sans PDF ─────────────────
regenerateBapPdf: protectedProcedure
.input(z.object({ invoiceId: z.number() }))
.mutation(async ({ input, ctx }) => {
const invoice = await getInvoiceById(input.invoiceId);
if (!invoice || (ctx.user.role !== 'admin' && invoice.userId !== ctx.user.id)) {
throw new TRPCError({ code: 'NOT_FOUND', message: 'Facture introuvable' });
}
if (!invoice.bapValidated) {
throw new TRPCError({ code: 'BAD_REQUEST', message: 'La facture n\'est pas validée BAP' });
}
const fs = await import('fs/promises');
const path = await import('path');
const { PDFDocument } = await import('pdf-lib');
const { localStoragePut, generateStorageKey } = await import('./localStorage');
const STORAGE_BASE_PATH = process.env.STORAGE_BASE_PATH || path.join(process.cwd(), 'storage');
if (!invoice.fileKey && !invoice.fileUrl) {
throw new TRPCError({ code: 'BAD_REQUEST', message: 'Fichier PDF source introuvable' });
}
let pdfBytes: Buffer;
const sourcePath = path.join(STORAGE_BASE_PATH, invoice.fileKey || '');
try {
pdfBytes = await fs.readFile(sourcePath);
} catch (_) {
const fileUrl = invoice.fileUrl;
if (!fileUrl) throw new TRPCError({ code: 'BAD_REQUEST', message: 'Fichier PDF source introuvable' });
let absoluteUrl = fileUrl;
if (fileUrl.startsWith('/')) {
const baseUrl = process.env.APP_BASE_URL || `http://localhost:${process.env.PORT || 3000}`;
absoluteUrl = `${baseUrl}${fileUrl}`;
}
const response = await fetch(absoluteUrl);
if (!response.ok) throw new TRPCError({ code: 'BAD_REQUEST', message: 'Impossible de télécharger le PDF source' });
pdfBytes = Buffer.from(await response.arrayBuffer());
}
const pdfDoc = await PDFDocument.load(pdfBytes);
// Récupération de la signature du service
let sigBytesForCartouche: Buffer | undefined;
let sigMimeForCartouche: 'image/png' | 'image/jpeg' | undefined;
let signatureName: string | null = null;
if (invoice.serviceConcerne) {
const serviceAssociations = await getServiceSignaturesByUser(ctx.user.id);
const assoc = serviceAssociations.find(a => a.serviceName.toLowerCase() === (invoice.serviceConcerne || '').toLowerCase());
if (assoc) {
const sig = await getSignatureById(assoc.signatureId);
if (sig) {
signatureName = `${sig.firstName} ${sig.lastName}`;
try {
const sigImagePath = path.join(STORAGE_BASE_PATH, sig.imageKey);
try { sigBytesForCartouche = await fs.readFile(sigImagePath); } catch (_) {
const sigUrl = sig.imageUrl;
if (sigUrl) {
let absoluteSigUrl = sigUrl;
if (sigUrl.startsWith('/')) {
const baseUrl = process.env.APP_BASE_URL || `http://localhost:${process.env.PORT || 3000}`;
absoluteSigUrl = `${baseUrl}${sigUrl}`;
}
const sigResp = await fetch(absoluteSigUrl);
if (sigResp.ok) sigBytesForCartouche = Buffer.from(await sigResp.arrayBuffer());
}
}
sigMimeForCartouche = sig.imageKey.toLowerCase().endsWith('.png') ? 'image/png' : 'image/jpeg';
} catch (_) { /* ignore */ }
}
}
}
// Placement du cartouche BAP
const validatedAt = invoice.bapValidatedAt ? new Date(invoice.bapValidatedAt) : new Date();
await drawBapCartouche(pdfDoc, pdfBytes, {
typeAchat: invoice.typeAchat || 'N/A',
destinataire: (invoice as any).recipientName || 'TOUS',
serviceConcerne: invoice.serviceConcerne || '-',
ventilationComptable: invoice.ventilationComptable || '-',
validatedAt,
signatureImageBytes: sigBytesForCartouche,
signatureMimeType: sigMimeForCartouche,
signatureName: signatureName || undefined,
});
const signedPdfBytes = await pdfDoc.save();
const _bapDateStr = invoice.invoiceDate ? new Date(invoice.invoiceDate).toISOString().slice(0, 10) : new Date().toISOString().slice(0, 10);
const _bapSupplier = (invoice.supplierName || 'Fournisseur').replace(/[^a-zA-Z0-9\u00e0-\u00ff \-]/g, '').trim();
const _bapNumber = (invoice.invoiceNumber || '').replace(/[^a-zA-Z0-9\-]/g, '').trim();
const bapFilename = [_bapDateStr, _bapSupplier, _bapNumber].filter(Boolean).join(' - ') + '.pdf';
const bapKey = generateStorageKey(ctx.user.id, bapFilename);
const { url } = await localStoragePut(bapKey, Buffer.from(signedPdfBytes), 'application/pdf');
// Mettre à jour la dernière entrée bapHistory de cette facture avec le nouveau pdfUrl
const allEntries = ctx.user.role === 'admin' ? await getAllBapHistory() : await getBapHistoryByUser(ctx.user.id);
const latestEntry = allEntries
.filter(e => e.invoiceId === input.invoiceId)
.sort((a, b) => new Date(b.validatedAt).getTime() - new Date(a.validatedAt).getTime())[0];
if (latestEntry) {
await updateBapHistoryPdfUrl(latestEntry.id, url);
}
// Mettre à jour le statut export de la facture
await updateInvoice(input.invoiceId, { exportStatus: 'exported' as any });
return { success: true, pdfUrl: url };
}),
// // ── Historique BAP ──────────────────── // // ── Historique BAP ────────────────────
search: protectedProcedure search: protectedProcedure
.input(z.object({ query: z.string() })) .input(z.object({ query: z.string() }))
.query(async ({ input, ctx }) => { .query(async ({ input, ctx }) => {
if (ctx.user.role === 'admin') {
return searchInvoices(null, input.query);
}
return searchInvoices(ctx.user.id, input.query); return searchInvoices(ctx.user.id, input.query);
}), }),
@@ -908,7 +1024,7 @@ export const appRouter = router({
delete: protectedProcedure delete: protectedProcedure
.input(z.object({ id: z.number() })) .input(z.object({ id: z.number() }))
.mutation(async ({ input, ctx }) => { .mutation(async ({ input, ctx }) => {
const entries = await getBapHistoryByUser(ctx.user.id); const entries = ctx.user.role === 'admin' ? await getAllBapHistory() : await getBapHistoryByUser(ctx.user.id);
const entry = entries.find(e => e.id === input.id); const entry = entries.find(e => e.id === input.id);
if (!entry) throw new TRPCError({ code: 'NOT_FOUND' }); if (!entry) throw new TRPCError({ code: 'NOT_FOUND' });
await deleteBapHistoryEntry(input.id); await deleteBapHistoryEntry(input.id);
@@ -919,12 +1035,12 @@ export const appRouter = router({
regenerate: protectedProcedure regenerate: protectedProcedure
.input(z.object({ id: z.number() })) .input(z.object({ id: z.number() }))
.mutation(async ({ input, ctx }) => { .mutation(async ({ input, ctx }) => {
const entries = await getBapHistoryByUser(ctx.user.id); const entries = ctx.user.role === 'admin' ? await getAllBapHistory() : await getBapHistoryByUser(ctx.user.id);
const entry = entries.find(e => e.id === input.id); const entry = entries.find(e => e.id === input.id);
if (!entry) throw new TRPCError({ code: 'NOT_FOUND' }); if (!entry) throw new TRPCError({ code: 'NOT_FOUND' });
const invoice = await getInvoiceById(entry.invoiceId); const invoice = await getInvoiceById(entry.invoiceId);
if (!invoice || invoice.userId !== ctx.user.id) { if (!invoice || (ctx.user.role !== 'admin' && invoice.userId !== ctx.user.id)) {
throw new TRPCError({ code: 'NOT_FOUND', message: 'Facture source introuvable' }); throw new TRPCError({ code: 'NOT_FOUND', message: 'Facture source introuvable' });
} }
@@ -1056,10 +1172,11 @@ export const appRouter = router({
sftpAutoExport: z.number().optional(), sftpAutoExport: z.number().optional(),
llmLogsRetentionMonths: z.number().optional(), llmLogsRetentionMonths: z.number().optional(),
learningConfidenceThreshold: z.number().min(1).optional(), learningConfidenceThreshold: z.number().min(1).optional(),
aiProvider: z.enum(["mistral", "manus"]).optional(), aiProvider: z.enum(["mistral", "manus", "gemini"]).optional(),
mistralApiKey: z.string().optional(), mistralApiKey: z.string().optional(),
manusForgeApiKey: z.string().optional(), manusForgeApiKey: z.string().optional(),
manusForgeApiUrl: z.string().optional(), manusForgeApiUrl: z.string().optional(),
geminiApiKey: z.string().optional(),
})) }))
.mutation(async ({ input, ctx }) => { .mutation(async ({ input, ctx }) => {
await upsertUserSettings({ await upsertUserSettings({
@@ -1138,7 +1255,7 @@ export const appRouter = router({
); );
const validInvoices = invoices.filter( const validInvoices = invoices.filter(
inv => inv && inv.userId === ctx.user.id inv => inv && (ctx.user.role === 'admin' || inv.userId === ctx.user.id)
); );
// Return invoice data for Excel generation on client side // Return invoice data for Excel generation on client side
@@ -1189,7 +1306,7 @@ export const appRouter = router({
); );
const invalidInvoices = invoices.filter( const invalidInvoices = invoices.filter(
inv => !inv || inv.userId !== ctx.user.id || (inv.qualityScore || 0) < 100 inv => !inv || (ctx.user.role !== 'admin' && inv.userId !== ctx.user.id) || (inv.qualityScore || 0) < 100
); );
if (invalidInvoices.length > 0) { if (invalidInvoices.length > 0) {
@@ -1448,7 +1565,7 @@ export const appRouter = router({
for (const invoiceId of input.invoiceIds) { for (const invoiceId of input.invoiceIds) {
try { try {
const invoice = await getInvoiceById(invoiceId); const invoice = await getInvoiceById(invoiceId);
if (!invoice || invoice.userId !== ctx.user.id) { if (!invoice || (ctx.user.role !== 'admin' && invoice.userId !== ctx.user.id)) {
errorCount++; errorCount++;
continue; continue;
} }
@@ -1497,7 +1614,12 @@ export const appRouter = router({
}), }),
deleteAll: protectedProcedure.mutation(async ({ ctx }) => { deleteAll: protectedProcedure.mutation(async ({ ctx }) => {
await deleteAllImportLogs(ctx.user.id); // Les admins suppriment tous les logs, les utilisateurs standard suppriment les leurs
if (ctx.user.role === 'admin') {
await deleteAllImportLogs(null);
} else {
await deleteAllImportLogs(ctx.user.id);
}
return { success: true }; return { success: true };
}), }),
}), }),

17
todo.md
View File

@@ -667,3 +667,20 @@
- [x] Job cron WebDev : vérification périodique selon fréquence configurée (setInterval en mémoire) - [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] 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 - [x] Onglet Paramétrage : formulaire credentials FreePro web, fréquence, date antériorité, bouton "Forcer récupération", statut dernière récupération
## Colonne Source import dans l'historique des imports
- [x] Ajouter colonne importSource (file/folder/email) dans la table importLogs (schéma + migration)
- [x] Enregistrer importSource="file" lors d'un import manuel (routers.ts)
- [x] Enregistrer importSource="email" lors d'un import par email (emailImportService.ts)
- [x] Enregistrer importSource="folder" lors d'un import par dossier (folderImportService.ts)
- [x] Afficher la colonne "Source" avec badge coloré dans le tableau History.tsx (Email=bleu, Dossier=violet, Fichier=gris)
- [x] Afficher la source dans le dialog de détails de l'import
## Listes globales partagées entre tous les utilisateurs
- [ ] Modifier getAccountingAllocationsByUser → retourner toutes les ventilations (sans filtre userId)
- [ ] Modifier getAutomationRulesByUser → retourner tous les automatismes (sans filtre userId)
- [ ] Modifier getDepartmentsByUser → retourner tous les services (sans filtre userId)
- [ ] Modifier getSignaturesByUser → retourner toutes les signatures (sans filtre userId)
- [ ] Modifier getServiceSignaturesByUser → retourner toutes les signatures de service (sans filtre userId)
- [ ] Migrer les données en production : dédoublonner les listes fusionnées
- [ ] Déployer en production