Compare commits
10 Commits
22b34fcf1e
...
827c9ec41e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
827c9ec41e | ||
|
|
46a61fca0e | ||
|
|
1951b9b9d1 | ||
|
|
a994ec2e61 | ||
|
|
8ee10602d3 | ||
|
|
d4c9bf635a | ||
|
|
f7d3f6d5b3 | ||
|
|
b97efb5c57 | ||
|
|
756f8ab392 | ||
|
|
1a09c98ef5 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -105,3 +105,4 @@ temp/
|
||||
*.db
|
||||
*.sqlite
|
||||
*.sqlite3
|
||||
.project-config.json
|
||||
|
||||
9
.manus/db/db-query-1781681544555.json
Normal file
9
.manus/db/db-query-1781681544555.json
Normal 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
|
||||
}
|
||||
@@ -23,6 +23,9 @@ import {
|
||||
RotateCcw,
|
||||
Package,
|
||||
X,
|
||||
Upload,
|
||||
FolderOpen,
|
||||
Mail,
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { toast } from "sonner";
|
||||
@@ -307,6 +310,7 @@ export default function History() {
|
||||
<TableRow className="bg-gray-50">
|
||||
<TableHead className="font-semibold">Fichier 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">Importées</TableHead>
|
||||
<TableHead className="font-semibold text-center">Doublons</TableHead>
|
||||
@@ -326,6 +330,24 @@ export default function History() {
|
||||
<TableCell className="text-sm text-gray-600">
|
||||
{new Date(log.importedAt).toLocaleString("fr-FR")}
|
||||
</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">
|
||||
<Badge variant="outline" className="font-mono">
|
||||
{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-sm font-semibold">{new Date(selectedLog.importedAt).toLocaleString("fr-FR")}</p>
|
||||
</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>
|
||||
|
||||
{/* Statistiques */}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState } from "react";
|
||||
import React, { useState, useMemo } from "react";
|
||||
import DashboardLayout from "@/components/DashboardLayout";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -155,7 +155,12 @@ export default function InvoicesBAP() {
|
||||
// Filter for BAP invoices only (Abonnement = NON, isSubscription = 0)
|
||||
const invoices = allInvoices?.filter(inv => inv.isSubscription === 0);
|
||||
// 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(
|
||||
{ invoiceIds: validatedInvoiceIds },
|
||||
{ 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({
|
||||
onSuccess: (data) => {
|
||||
toast.success(`${data.processed} facture(s) dévalidée(s) BAP avec succès`);
|
||||
@@ -995,11 +1015,14 @@ export default function InvoicesBAP() {
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-8 px-2 text-gray-400 border-gray-200 cursor-not-allowed"
|
||||
title="PDF non disponible"
|
||||
disabled
|
||||
className="h-8 px-2 text-orange-500 border-orange-300 hover:bg-orange-50"
|
||||
title="PDF non disponible — cliquer pour régénérer"
|
||||
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>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState } from "react";
|
||||
import { useState, useEffect } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
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 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() {
|
||||
const [username, setUsername] = 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({
|
||||
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) => {
|
||||
e.preventDefault();
|
||||
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 (
|
||||
<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">
|
||||
@@ -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>
|
||||
</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">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="username">Identifiant</Label>
|
||||
|
||||
@@ -677,12 +677,14 @@ export default function Settings() {
|
||||
const [sftpAutoExport, setSftpAutoExport] = useState(false);
|
||||
const [sftpRecipientFilter, setSftpRecipientFilter] = useState("");
|
||||
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 [manusForgeApiKey, setManusForgeApiKey] = useState("");
|
||||
const [manusForgeApiUrl, setManusForgeApiUrl] = useState("");
|
||||
const [geminiApiKey, setGeminiApiKey] = useState("");
|
||||
const [showMistralKey, setShowMistralKey] = useState(false);
|
||||
const [showManusKey, setShowManusKey] = useState(false);
|
||||
const [showGeminiKey, setShowGeminiKey] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (settings) {
|
||||
@@ -706,6 +708,7 @@ export default function Settings() {
|
||||
setMistralApiKey((settings as any).mistralApiKey || "");
|
||||
setManusForgeApiKey((settings as any).manusForgeApiKey || "");
|
||||
setManusForgeApiUrl((settings as any).manusForgeApiUrl || "");
|
||||
setGeminiApiKey((settings as any).geminiApiKey || "");
|
||||
}
|
||||
}, [settings]);
|
||||
|
||||
@@ -754,6 +757,7 @@ export default function Settings() {
|
||||
mistralApiKey,
|
||||
manusForgeApiKey,
|
||||
manusForgeApiUrl,
|
||||
geminiApiKey,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -853,7 +857,7 @@ export default function Settings() {
|
||||
<CardContent className="space-y-6 pt-6">
|
||||
|
||||
{/* Provider selector */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setAiProvider("mistral")}
|
||||
@@ -880,6 +884,36 @@ export default function Settings() {
|
||||
)}
|
||||
</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
|
||||
type="button"
|
||||
onClick={() => setAiProvider("manus")}
|
||||
@@ -938,6 +972,46 @@ export default function Settings() {
|
||||
</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 */}
|
||||
{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">
|
||||
@@ -993,14 +1067,14 @@ export default function Settings() {
|
||||
<div>
|
||||
<CardTitle className="text-xl">Configuration du modèle AI</CardTitle>
|
||||
<CardDescription className="mt-1">
|
||||
Paramètres du modèle d'extraction Mistral AI
|
||||
Paramètres avancés du modèle d'extraction IA
|
||||
</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6 pt-6">
|
||||
<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
|
||||
id="llmModel"
|
||||
value={llmModel}
|
||||
|
||||
1
drizzle/0032_volatile_jocasta.sql
Normal file
1
drizzle/0032_volatile_jocasta.sql
Normal file
@@ -0,0 +1 @@
|
||||
ALTER TABLE `importLogs` ADD `importSource` enum('file','folder','email') DEFAULT 'file' NOT NULL;
|
||||
1
drizzle/0033_strange_manta.sql
Normal file
1
drizzle/0033_strange_manta.sql
Normal file
@@ -0,0 +1 @@
|
||||
ALTER TABLE `users` MODIFY COLUMN `azureAdId` varchar(128);
|
||||
4
drizzle/0034_black_shadowcat.sql
Normal file
4
drizzle/0034_black_shadowcat.sql
Normal 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;
|
||||
2155
drizzle/meta/0032_snapshot.json
Normal file
2155
drizzle/meta/0032_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
2155
drizzle/meta/0033_snapshot.json
Normal file
2155
drizzle/meta/0033_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
2169
drizzle/meta/0034_snapshot.json
Normal file
2169
drizzle/meta/0034_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -225,6 +225,27 @@
|
||||
"when": 1781021650065,
|
||||
"tag": "0031_mean_squadron_supreme",
|
||||
"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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -9,7 +9,7 @@ export const users = mysqlTable("users", {
|
||||
/** Manus OAuth identifier (openId) - Optional for backward compatibility */
|
||||
openId: varchar("openId", { length: 64 }).unique(),
|
||||
/** Azure AD Object ID - Unique identifier from Azure AD */
|
||||
azureAdId: varchar("azureAdId", { length: 64 }).unique(),
|
||||
azureAdId: varchar("azureAdId", { length: 128 }).unique(),
|
||||
name: text("name"),
|
||||
email: varchar("email", { length: 320 }).notNull().unique(),
|
||||
/** Hashed password for local authentication (bcrypt) */
|
||||
@@ -149,10 +149,11 @@ export const userSettings = mysqlTable("userSettings", {
|
||||
// Seuil de confiance pour les apprentissages IA
|
||||
learningConfidenceThreshold: int("learningConfidenceThreshold").default(2).notNull(), // Nombre minimum d'applications pour marquer un apprentissage comme Confirmé
|
||||
// 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)
|
||||
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)
|
||||
geminiApiKey: text("geminiApiKey"), // Google Gemini API key (overrides env GEMINI_API_KEY)
|
||||
createdAt: timestamp("createdAt").defaultNow().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
|
||||
|
||||
// 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)
|
||||
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)
|
||||
geminiApiKey: text("geminiApiKey"), // Google Gemini API key (overrides env GEMINI_API_KEY)
|
||||
|
||||
createdAt: timestamp("createdAt").defaultNow().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
|
||||
errorDetails: text("errorDetails"), // JSON array of error messages
|
||||
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(),
|
||||
});
|
||||
|
||||
|
||||
@@ -8,4 +8,5 @@ export const ENV = {
|
||||
forgeApiUrl: process.env.BUILT_IN_FORGE_API_URL ?? "",
|
||||
forgeApiKey: process.env.BUILT_IN_FORGE_API_KEY ?? "",
|
||||
mistralApiKey: process.env.MISTRAL_API_KEY ?? "",
|
||||
geminiApiKey: process.env.GEMINI_API_KEY ?? "",
|
||||
};
|
||||
|
||||
@@ -10,10 +10,11 @@ import { registerOAuthRoutes } from "./oauth";
|
||||
import { appRouter } from "../routers";
|
||||
import { createContext } from "./context";
|
||||
import { serveStatic, setupVite } from "./vite";
|
||||
import { getAllUsers } from "../db";
|
||||
import { getAllUsers, getUserByAzureAdId, getUserByEmail, upsertUser } from "../db";
|
||||
import { startEmailImportService } from "../emailImportService";
|
||||
import { startFolderImportService } from "../folderImportService";
|
||||
import { getImportSettingsByUser } from "../db";
|
||||
import { handleAzureCallback, isAzureAdConfigured, generateToken } from "../auth";
|
||||
|
||||
function isPortAvailable(port: number): Promise<boolean> {
|
||||
return new Promise(resolve => {
|
||||
@@ -143,6 +144,86 @@ async function startServer() {
|
||||
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
|
||||
app.use(
|
||||
"/api/trpc",
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
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";
|
||||
if (provider === "mistral") {
|
||||
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
|
||||
const forgeUrl = settings?.manusForgeApiUrl || ENV.forgeApiUrl || "https://forge.manus.ai";
|
||||
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";
|
||||
if (provider === "mistral") {
|
||||
return settings?.mistralApiKey || ENV.mistralApiKey || "";
|
||||
}
|
||||
if (provider === "gemini") {
|
||||
return settings?.geminiApiKey || ENV.geminiApiKey || "";
|
||||
}
|
||||
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
|
||||
* pour choisir dynamiquement le moteur IA (Mistral ou Manus).
|
||||
* pour choisir dynamiquement le moteur IA (Mistral, Manus ou Gemini).
|
||||
*/
|
||||
export async function invokeLLMWithUserSettings(
|
||||
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> {
|
||||
const apiKey = getApiKeyWithSettings(userSettings);
|
||||
if (!apiKey || apiKey.trim().length === 0) {
|
||||
@@ -328,7 +342,7 @@ export async function invokeLLMWithUserSettings(
|
||||
response_format,
|
||||
} = params;
|
||||
|
||||
const model = isMistral ? "mistral-large-latest" : "gemini-2.5-flash";
|
||||
const model = getModelForProvider(provider);
|
||||
|
||||
const payload: Record<string, unknown> = {
|
||||
model,
|
||||
@@ -342,7 +356,8 @@ export async function invokeLLMWithUserSettings(
|
||||
|
||||
payload.max_tokens = 32768;
|
||||
|
||||
if (!isMistral) {
|
||||
// thinking uniquement pour Manus Forge (proxy Gemini)
|
||||
if (provider === "manus") {
|
||||
payload.thinking = { budget_tokens: 128 };
|
||||
}
|
||||
|
||||
|
||||
33
server/db.ts
33
server/db.ts
@@ -268,11 +268,17 @@ export async function deleteInvoice(id: number) {
|
||||
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();
|
||||
if (!db) return [];
|
||||
|
||||
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)
|
||||
.where(
|
||||
and(
|
||||
@@ -485,10 +491,15 @@ export async function getAllImportLogs(): Promise<ImportLog[]> {
|
||||
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();
|
||||
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 =============
|
||||
@@ -555,10 +566,10 @@ export async function upsertImportSettings(data: InsertImportSettings): Promise<
|
||||
|
||||
// ============= DEPARTMENT LIST OPERATIONS =============
|
||||
|
||||
export async function getDepartmentsByUser(userId: number): Promise<Department[]> {
|
||||
export async function getDepartmentsByUser(_userId?: number): Promise<Department[]> {
|
||||
const db = await getDb();
|
||||
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> {
|
||||
@@ -576,10 +587,10 @@ export async function deleteDepartment(id: number): Promise<void> {
|
||||
|
||||
// ============= ACCOUNTING ALLOCATION LIST OPERATIONS =============
|
||||
|
||||
export async function getAccountingAllocationsByUser(userId: number): Promise<AccountingAllocation[]> {
|
||||
export async function getAccountingAllocationsByUser(_userId?: number): Promise<AccountingAllocation[]> {
|
||||
const db = await getDb();
|
||||
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> {
|
||||
@@ -731,10 +742,10 @@ export async function initializeDefaultLlmFields(userId: number): Promise<void>
|
||||
|
||||
// ============= SIGNATURES HELPERS =============
|
||||
|
||||
export async function getSignaturesByUser(userId: number): Promise<Signature[]> {
|
||||
export async function getSignaturesByUser(_userId?: number): Promise<Signature[]> {
|
||||
const db = await getDb();
|
||||
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> {
|
||||
@@ -762,10 +773,10 @@ export async function deleteSignature(id: number): Promise<void> {
|
||||
|
||||
// ============= SERVICE SIGNATURES HELPERS =============
|
||||
|
||||
export async function getServiceSignaturesByUser(userId: number): Promise<ServiceSignature[]> {
|
||||
export async function getServiceSignaturesByUser(_userId?: number): Promise<ServiceSignature[]> {
|
||||
const db = await getDb();
|
||||
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> {
|
||||
|
||||
@@ -92,6 +92,7 @@ async function processEmailAttachment(
|
||||
mistralApiKey: settings.mistralApiKey,
|
||||
manusForgeApiKey: settings.manusForgeApiKey,
|
||||
manusForgeApiUrl: settings.manusForgeApiUrl,
|
||||
geminiApiKey: (settings as any).geminiApiKey || null,
|
||||
} : undefined;
|
||||
|
||||
// Extract invoices
|
||||
@@ -232,6 +233,7 @@ async function processEmailAttachment(
|
||||
duplicateDetails: duplicateDetails.length > 0 ? JSON.stringify(duplicateDetails) : null,
|
||||
errorDetails: errorDetails.length > 0 ? JSON.stringify(errorDetails) : null,
|
||||
warningMessage,
|
||||
importSource: "email",
|
||||
});
|
||||
|
||||
console.log(`[EmailImport] Successfully processed attachment: ${fileName}`);
|
||||
|
||||
@@ -191,6 +191,7 @@ async function processFolderFile(
|
||||
errors: errorsCount,
|
||||
duplicateDetails: duplicateDetails.length > 0 ? JSON.stringify(duplicateDetails) : null,
|
||||
errorDetails: errorDetails.length > 0 ? JSON.stringify(errorDetails) : null,
|
||||
importSource: "folder",
|
||||
});
|
||||
|
||||
// Move file to processed folder
|
||||
|
||||
@@ -218,6 +218,7 @@ export const appRouter = router({
|
||||
mistralApiKey: (settings as any).mistralApiKey || null,
|
||||
manusForgeApiKey: (settings as any).manusForgeApiKey || null,
|
||||
manusForgeApiUrl: (settings as any).manusForgeApiUrl || null,
|
||||
geminiApiKey: (settings as any).geminiApiKey || null,
|
||||
} : undefined;
|
||||
|
||||
// Extract invoices
|
||||
@@ -370,6 +371,7 @@ export const appRouter = router({
|
||||
errors: errorsCount,
|
||||
duplicateDetails: JSON.stringify(duplicateDetails),
|
||||
errorDetails: JSON.stringify(errorDetails),
|
||||
importSource: "file",
|
||||
});
|
||||
|
||||
} catch (error: any) {
|
||||
@@ -398,7 +400,7 @@ export const appRouter = router({
|
||||
.input(z.object({ id: z.number() }))
|
||||
.query(async ({ input, ctx }) => {
|
||||
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" });
|
||||
}
|
||||
return invoice;
|
||||
@@ -424,7 +426,7 @@ export const appRouter = router({
|
||||
}))
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
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" });
|
||||
}
|
||||
|
||||
@@ -440,7 +442,7 @@ export const appRouter = router({
|
||||
.input(z.object({ id: z.number() }))
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
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" });
|
||||
}
|
||||
|
||||
@@ -452,7 +454,7 @@ export const appRouter = router({
|
||||
.input(z.object({ id: z.number() }))
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
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" });
|
||||
}
|
||||
// Vérifier les critères de validation BAP
|
||||
@@ -616,9 +618,11 @@ export const appRouter = router({
|
||||
|
||||
// ── Mise à jour de la facture ─────────────────────────────────────
|
||||
const validatedAt = new Date();
|
||||
const newExportStatus = (exportPath || pdfUrl) ? 'exported' : 'not_exported';
|
||||
await updateInvoice(input.id, {
|
||||
bapValidated: 1,
|
||||
bapValidatedAt: validatedAt,
|
||||
exportStatus: newExportStatus as any,
|
||||
});
|
||||
|
||||
// ── Enregistrement dans l'historique BAP ─────────────────────────
|
||||
@@ -663,7 +667,7 @@ export const appRouter = router({
|
||||
let processed = 0;
|
||||
for (const id of input.invoiceIds) {
|
||||
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, {
|
||||
bapValidated: 0,
|
||||
bapValidatedAt: null,
|
||||
@@ -677,7 +681,7 @@ export const appRouter = router({
|
||||
// ── Validation BAP en masse ──────────────────────────────
|
||||
validateBAPBulk: protectedProcedure
|
||||
.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)
|
||||
const eligible = allInvoices.filter((inv: any) =>
|
||||
(inv.qualityScore || 0) >= 100 &&
|
||||
@@ -816,7 +820,8 @@ export const appRouter = router({
|
||||
console.warn(`[BAP Bulk] Erreur PDF facture ${invoice.id}:`, pdfError.message);
|
||||
}
|
||||
// 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
|
||||
await createBapHistoryEntry({
|
||||
userId: ctx.user.id,
|
||||
@@ -858,7 +863,7 @@ export const appRouter = router({
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
// Relance UNIQUEMENT les automatismes (sans re-extraction LLM)
|
||||
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));
|
||||
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 };
|
||||
}),
|
||||
|
||||
// ── 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 ────────────────────
|
||||
search: protectedProcedure
|
||||
.input(z.object({ query: z.string() }))
|
||||
.query(async ({ input, ctx }) => {
|
||||
if (ctx.user.role === 'admin') {
|
||||
return searchInvoices(null, input.query);
|
||||
}
|
||||
return searchInvoices(ctx.user.id, input.query);
|
||||
}),
|
||||
|
||||
@@ -908,7 +1024,7 @@ export const appRouter = router({
|
||||
delete: protectedProcedure
|
||||
.input(z.object({ id: z.number() }))
|
||||
.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);
|
||||
if (!entry) throw new TRPCError({ code: 'NOT_FOUND' });
|
||||
await deleteBapHistoryEntry(input.id);
|
||||
@@ -919,12 +1035,12 @@ export const appRouter = router({
|
||||
regenerate: protectedProcedure
|
||||
.input(z.object({ id: z.number() }))
|
||||
.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);
|
||||
if (!entry) throw new TRPCError({ code: 'NOT_FOUND' });
|
||||
|
||||
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' });
|
||||
}
|
||||
|
||||
@@ -1056,10 +1172,11 @@ export const appRouter = router({
|
||||
sftpAutoExport: z.number().optional(),
|
||||
llmLogsRetentionMonths: z.number().optional(),
|
||||
learningConfidenceThreshold: z.number().min(1).optional(),
|
||||
aiProvider: z.enum(["mistral", "manus"]).optional(),
|
||||
aiProvider: z.enum(["mistral", "manus", "gemini"]).optional(),
|
||||
mistralApiKey: z.string().optional(),
|
||||
manusForgeApiKey: z.string().optional(),
|
||||
manusForgeApiUrl: z.string().optional(),
|
||||
geminiApiKey: z.string().optional(),
|
||||
}))
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
await upsertUserSettings({
|
||||
@@ -1138,7 +1255,7 @@ export const appRouter = router({
|
||||
);
|
||||
|
||||
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
|
||||
@@ -1189,7 +1306,7 @@ export const appRouter = router({
|
||||
);
|
||||
|
||||
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) {
|
||||
@@ -1448,7 +1565,7 @@ export const appRouter = router({
|
||||
for (const invoiceId of input.invoiceIds) {
|
||||
try {
|
||||
const invoice = await getInvoiceById(invoiceId);
|
||||
if (!invoice || invoice.userId !== ctx.user.id) {
|
||||
if (!invoice || (ctx.user.role !== 'admin' && invoice.userId !== ctx.user.id)) {
|
||||
errorCount++;
|
||||
continue;
|
||||
}
|
||||
@@ -1497,7 +1614,12 @@ export const appRouter = router({
|
||||
}),
|
||||
|
||||
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 };
|
||||
}),
|
||||
}),
|
||||
|
||||
17
todo.md
17
todo.md
@@ -667,3 +667,20 @@
|
||||
- [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
|
||||
|
||||
## 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
|
||||
|
||||
Reference in New Issue
Block a user