38 Commits
main ... master

Author SHA1 Message Date
Santinova Deployment
5f6c61d2f0 fix: retirer les alias URL historiques
Some checks failed
Validation applicative / TypeScript, tests et build (push) Failing after 2m16s
2026-10-25 00:47:39 +00:00
Santinova Deployment
fd970db7fb feat: normaliser les URL recette par entité
Some checks failed
Validation applicative / TypeScript, tests et build (push) Failing after 2m55s
2026-10-24 17:06:48 +00:00
Manus
16759b4ad5 feat: purge import history by retention 2026-10-09 10:22:46 +00:00
Manus
6d72547509 fix: paginate import history and report 2026-10-09 09:58:08 +00:00
Manus
939dddd1d8 fix: require BAP before original PDF exports 2026-10-09 08:44:02 +00:00
Manus
e402233120 fix: transmettre le moteur Référentiel IA 2026-10-09 07:30:44 +00:00
Manus
3b0be885e2 fix: compacter extraction pour Santino rapide 2026-10-02 21:36:27 +00:00
Manus
f9434d3a79 fix: autoriser le reimport apres suppression 2026-10-02 21:15:28 +00:00
Manus
1617267128 fix: expliciter le résultat des imports manuels 2026-10-02 21:10:42 +00:00
Manus
ba4e42c7f1 fix: recalculer les scores de qualité à l import 2026-10-02 20:59:23 +00:00
Manus
70f867d62b fix: relancer les sources vides après purge 2026-10-02 20:46:21 +00:00
Manus
b09dccb078 fix: accepter les réponses IA avec contrôles OCR 2026-10-02 20:05:45 +00:00
Manus
c5f0c9eed6 fix: rétablir la suppression de facture en recette 2026-10-02 17:05:14 +00:00
Manus
aebdafd026 Checkpoint: Paramètres > Intelligence IA affiche désormais automatiquement le moteur, et le modèle quand il est fourni, décidés par le Référentiel IA dès sa sélection. La consultation ne transmet aucune facture ; un état de chargement et une reprise sur erreur sont affichés. Test Référentiel IA, TypeScript et build validés. 2026-10-02 15:13:56 +00:00
Manus
32b07370d8 Checkpoint: Correction Référentiel IA déployée en recette : une variable de jeton vide ne masque plus le secret Docker monté. La mutation exacte du bouton Paramètres > Intelligence IA est validée avec une session de contrôle : connexion réussie, application Démat. Facturation DSI et moteur Manus Forge. Conteneur healthy, routes HTTP 200 et volume PDF (301 fichiers) conservés. 2026-10-02 15:07:38 +00:00
Manus
edb4726d5f Checkpoint: Correction recette : une variable REFERENTIEL_IA_TOKEN vide masquait le secret Docker monté. Le serveur privilégie désormais un jeton explicite non vide et bascule vers le fichier secret sinon. Test de non-régression ajouté ; 89 tests, TypeScript et build validés. 2026-10-02 15:01:42 +00:00
Manus
eba192e159 docs: record controlled referential ia invocation 2026-09-30 16:38:06 +00:00
Manus
11c346153c fix: preserve recorded connector migration 2026-09-30 16:37:11 +00:00
Manus
136797ca36 Merge remote-tracking branch 'user_github/main'
# Conflicts:
#	server/webImportSourcesMigration.test.ts
#	todo.md
2026-09-30 16:35:50 +00:00
Manus
84cb2ac805 docs: record recipe referential ia activation 2026-09-30 16:34:40 +00:00
Manus
81a470cf60 fix: default recipe referential gateway url 2026-09-30 16:27:20 +00:00
Manus
5eb8c8b3ac fix: mount referentiel ia token as docker secret 2026-09-30 16:25:53 +00:00
Manus
e9d25d9270 feat: route invoice analysis through referentiel ia 2026-09-30 16:20:22 +00:00
Manus
584e9631e5 feat: lancer manuellement les imports SFR 2026-09-30 16:46:05 +02:00
Manus
0d65592d75 feat: lancer manuellement les imports SFR 2026-09-30 16:42:27 +02:00
Manus
e08df31909 Checkpoint: Ajout du déclenchement manuel pour les connecteurs web SFR : confirmation explicite, automate Chromium côté serveur, récupération des PDF par session authentifiée et injection dans le flux existant de stockage/dédoublonnage/analyse. Le bouton est désactivé pour les types sans moteur réellement livré. Le résultat affiche factures importées et doublons, sans créer de planification. Chromium et playwright-core sont intégrés à l’image. Validation locale : 77 tests, TypeScript et build. 2026-09-30 14:39:43 +00:00
Manus
405649c606 Checkpoint: Création de connecteur web : l’URL est normalisée, la table est présente sur les trois environnements et le flux HTTPS de production a été validé avec le même profil utilisateur (création puis suppression sans résidu). Toute erreur de persistance est désormais remplacée côté navigateur par un message générique ; les logs ne conservent que le code technique MySQL/Drizzle, jamais la requête ni ses paramètres. Recette et production déployées, santé, MySQL, routes HTTP 200 et volumes PDF vérifiés. Suite validée : 75 tests, TypeScript et build. 2026-09-30 14:28:48 +00:00
Manus
d2937fd26a fix: journaliser le code SQL sans paramètre 2026-09-30 16:03:54 +02:00
Manus
a9c81b4238 fix: masquer les erreurs SQL des connecteurs web 2026-09-30 15:57:09 +02:00
Manus
b294c11a52 Checkpoint: Les erreurs de création de connecteur sont désormais neutralisées côté API : aucune requête SQL ni aucun paramètre de connexion ne peut être renvoyé au navigateur. Un test dédié le garantit. Le flux public HTTPS de production a été validé pour le même profil utilisateur : création, normalisation https et suppression réussies, sans ligne résiduelle. 74 tests, TypeScript et build validés. 2026-09-30 13:55:08 +00:00
Manus
343ad71a59 fix: rendre la migration des connecteurs idempotente 2026-09-30 15:27:05 +02:00
Manus
137c1f0652 Checkpoint: La migration de création de webImportSources est rendue idempotente avec CREATE TABLE IF NOT EXISTS. Un test garantit la présence des colonnes de connecteur et l’absence de modification de la table factures. 73 tests, TypeScript et build validés ; aucune donnée métier n’a été modifiée. 2026-09-30 13:26:07 +00:00
Manus
77d57f4e42 fix: normaliser les URLs des connecteurs web 2026-09-30 14:55:13 +02:00
Manus
26eec3831a Checkpoint: Les connecteurs web acceptent désormais les noms de domaine saisis sans protocole : HTTPS est ajouté automatiquement dans l’interface et à l’API. Les URL HTTP(S) valides restent acceptées et les adresses incorrectes affichent un message métier clair. Sept tests dédiés, la suite complète (71 tests), TypeScript et build ont été validés. 2026-09-30 12:52:53 +00:00
Manus
daa5fb8463 fix: recalculer le score après correction manuelle 2026-09-30 12:20:12 +02:00
Manus
082e6d8326 Checkpoint: Le score de qualité est désormais recalculé et persisté par le serveur après chaque modification manuelle de facture. Il s’appuie sur les champs obligatoires configurés pour le propriétaire de la facture, conserve un référentiel par défaut en l’absence de configuration et accepte explicitement un compte ayant rendu tous ses champs optionnels. Le calcul local non persisté de la page détail est supprimé. 64 tests, TypeScript et build validés. 2026-09-30 10:16:27 +00:00
Manus
5aa2b9e285 Checkpoint: Réception automatique e-mail sécurisée : le planificateur est créé uniquement après un test IMAP concluant, les erreurs d’authentification sont affichées explicitement et ne déclenchent plus de boucle. Les administrateurs peuvent sélectionner, démarrer, arrêter et suivre le compte source déjà configuré sans afficher ni recopier de secrets. Tests de démarrage réussi/refusé, arrêt et ciblage de compte ajoutés ; 59 tests, TypeScript et build validés. 2026-09-24 09:34:22 +00:00
Manus
fcde121438 Checkpoint: La version f7cfa73 est déployée en recette et production. Les règles de destination sont résolues facture par facture pour les exportations d’abonnements, avec repli des paramètres globaux. Les deux conteneurs sont sains, les routes abonnements répondent HTTP 200, les volumes PDF sont montés et le dashboard de production voit l’application active. 2026-09-04 10:57:39 +00:00
61 changed files with 4984 additions and 276 deletions

View File

@@ -43,6 +43,7 @@ RUN apt-get update && apt-get install -y \
poppler-utils \
libvips42 \
curl \
chromium \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app

View File

@@ -3,8 +3,8 @@
"name": "Démat. Facturation DSI",
"category": "SANTINOVA",
"urls": {
"recette": "https://demat-facturation.recette.santinova-soft.org",
"prod": "https://demat-facturation.santinova-soft.org"
"recette": "https://demat-facturation-santinova.recette.santinova-soft.org",
"prod": "https://demat-facturation-santinova.santinova-soft.org"
},
"containerName": "demat-facturation-app",
"image": "images/demat-facturation-dsi.jpg",

View File

@@ -110,6 +110,7 @@ const MONTHS = [
];
const YEARS = Array.from({ length: 5 }, (_, index) => String(new Date().getFullYear() - index));
const IMPORT_LOG_PAGE_SIZE = 200;
function formatAccount(account: Pick<EmailAccount, "userName" | "userEmail" | "emailAddress">) {
const owner = account.userName?.trim() || account.userEmail;
@@ -139,7 +140,11 @@ function TriggerBadge({ trigger }: { trigger: DetailedImportLog["importTrigger"]
export default function History() {
const { user } = useAuth();
const isAdmin = user?.role === "admin";
const { data: logs, isLoading } = trpc.importLogs.getByUser.useQuery();
const [page, setPage] = useState(0);
const { data: logsPage, isLoading } = trpc.importLogs.getByUser.useQuery({
limit: IMPORT_LOG_PAGE_SIZE,
offset: page * IMPORT_LOG_PAGE_SIZE,
});
const { data: emailAccounts, isLoading: accountsLoading } = trpc.emailImportService.getConfiguredAccounts.useQuery(undefined, {
enabled: isAdmin,
staleTime: 30_000,
@@ -148,7 +153,8 @@ export default function History() {
const checkAccountNowMutation = trpc.emailImportService.checkAccountNow.useMutation();
const utils = trpc.useUtils();
const detailedLogs = (logs ?? []) as DetailedImportLog[];
const detailedLogs = (logsPage?.items ?? []) as DetailedImportLog[];
const totalLogs = logsPage?.total ?? 0;
const accounts = (emailAccounts ?? []) as EmailAccount[];
const configuredAccounts = useMemo(() => accounts.filter((account) => account.isConfigured === 1), [accounts]);
@@ -161,6 +167,12 @@ export default function History() {
const [selectedLog, setSelectedLog] = useState<DetailedImportLog | null>(null);
const [selectedAccountId, setSelectedAccountId] = useState("none");
const [confirmManualCheckOpen, setConfirmManualCheckOpen] = useState(false);
const [retentionMonths, setRetentionMonths] = useState<3 | 6 | 12>(6);
const { data: retentionPreview, isLoading: retentionPreviewLoading } = trpc.importLogs.retentionPreview.useQuery(
{ months: retentionMonths },
{ enabled: isAdmin },
);
const retentionPurgeMutation = trpc.importLogs.purgeBeforeRetention.useMutation();
const selectedAccount = useMemo(
() => configuredAccounts.find((account) => String(account.userId) === selectedAccountId),
@@ -177,6 +189,22 @@ export default function History() {
}
};
const handleRetentionPurge = async () => {
try {
const result = await retentionPurgeMutation.mutateAsync({ months: retentionMonths });
setPage(0);
await Promise.all([
utils.importLogs.getByUser.invalidate(),
utils.importLogs.retentionPreview.invalidate(),
]);
toast.success("Purge de l’historique terminée", {
description: `${result.deletedCount.toLocaleString("fr-FR")} journal${result.deletedCount > 1 ? "x" : ""} antérieur${result.deletedCount > 1 ? "s" : ""} à ${result.months} mois supprimé${result.deletedCount > 1 ? "s" : ""}.`,
});
} catch (error: any) {
toast.error(error?.message || "Impossible de purger l’historique des imports.");
}
};
const handleManualEmailCheck = async () => {
if (!selectedAccount) return;
try {
@@ -238,25 +266,59 @@ export default function History() {
<h1 className="text-3xl font-bold">Historique des imports</h1>
<p className="mt-1 text-gray-500">Suivez la date, la source, le compte et le mode de déclenchement de chaque ajout.</p>
</div>
{detailedLogs.length > 0 && (
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="destructive" size="sm"><Trash2 className="mr-2 h-4 w-4" />Effacer l’historique</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Supprimer l’historique ?</AlertDialogTitle>
<AlertDialogDescription>Cette action est irréversible et supprime les enregistrements accessibles avec votre rôle.</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Annuler</AlertDialogCancel>
<AlertDialogAction onClick={handleDeleteAll} disabled={deleteAllMutation.isPending} className="bg-destructive text-destructive-foreground hover:bg-destructive/90">
{deleteAllMutation.isPending ? "Suppression…" : "Supprimer"}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)}
<div className="flex flex-wrap items-center gap-2">
{isAdmin && (
<>
<Select value={String(retentionMonths)} onValueChange={(value) => setRetentionMonths(Number(value) as 3 | 6 | 12)}>
<SelectTrigger className="h-9 w-40 text-sm"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="3">Conserver 3 mois</SelectItem>
<SelectItem value="6">Conserver 6 mois</SelectItem>
<SelectItem value="12">Conserver 1 an</SelectItem>
</SelectContent>
</Select>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="outline" size="sm" className="border-orange-300 bg-orange-50 text-orange-800 hover:bg-orange-100"><Trash2 className="mr-2 h-4 w-4" />Purger l’historique</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Purger les journaux anciens ?</AlertDialogTitle>
<AlertDialogDescription>
{retentionPreviewLoading ? "Calcul du volume à purger…" : `${(retentionPreview?.count ?? 0).toLocaleString("fr-FR")} journal${(retentionPreview?.count ?? 0) > 1 ? "x" : ""} antérieur${(retentionPreview?.count ?? 0) > 1 ? "s" : ""} à ${retentionMonths} mois seront supprimés.`}
{" Les factures, fichiers source et PDF ne seront pas supprimés."}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Annuler</AlertDialogCancel>
<AlertDialogAction onClick={handleRetentionPurge} disabled={retentionPreviewLoading || retentionPurgeMutation.isPending} className="bg-destructive text-destructive-foreground hover:bg-destructive/90">
{retentionPurgeMutation.isPending ? "Purge…" : "Confirmer la purge"}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
)}
{detailedLogs.length > 0 && (
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="destructive" size="sm"><Trash2 className="mr-2 h-4 w-4" />Effacer l’historique</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Supprimer l’historique ?</AlertDialogTitle>
<AlertDialogDescription>Cette action est irréversible et supprime les enregistrements accessibles avec votre rôle.</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Annuler</AlertDialogCancel>
<AlertDialogAction onClick={handleDeleteAll} disabled={deleteAllMutation.isPending} className="bg-destructive text-destructive-foreground hover:bg-destructive/90">
{deleteAllMutation.isPending ? "Suppression…" : "Supprimer"}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)}
</div>
</div>
{isAdmin && (
@@ -329,7 +391,14 @@ export default function History() {
<Select value={triggerFilter} onValueChange={(value) => setTriggerFilter(value as TriggerFilter)}><SelectTrigger className="h-8 w-32 bg-white text-sm"><SelectValue placeholder="Déclencheur" /></SelectTrigger><SelectContent><SelectItem value="all">Tout mode</SelectItem><SelectItem value="manual">Manuel</SelectItem><SelectItem value="automatic">Planifié</SelectItem><SelectItem value="unknown">Non tracé</SelectItem></SelectContent></Select>
{hasFilters && <Button variant="ghost" size="sm" className="h-8 px-2 text-gray-500" onClick={() => { setYearFilter("all"); setMonthFilter("all"); setSourceFilter("all"); setTriggerFilter("all"); }} title="Réinitialiser les filtres"><X className="h-4 w-4" /></Button>}
</div>
<span className="ml-auto text-sm text-gray-500">{filteredLogs.length} import{filteredLogs.length > 1 ? "s" : ""} · {counts.totalDetected} facture{counts.totalDetected > 1 ? "s" : ""} détectée{counts.totalDetected > 1 ? "s" : ""}</span>
<div className="ml-auto flex items-center gap-2 text-sm text-gray-500">
<span>{filteredLogs.length} import{filteredLogs.length > 1 ? "s" : ""} · {counts.totalDetected} facture{counts.totalDetected > 1 ? "s" : ""} détectée{counts.totalDetected > 1 ? "s" : ""}</span>
{totalLogs > IMPORT_LOG_PAGE_SIZE && <>
<Button variant="outline" size="sm" className="h-8" disabled={page === 0} onClick={() => setPage((current) => current - 1)}>Précédent</Button>
<span className="whitespace-nowrap">Page {page + 1}</span>
<Button variant="outline" size="sm" className="h-8" disabled={(page + 1) * IMPORT_LOG_PAGE_SIZE >= totalLogs} onClick={() => setPage((current) => current + 1)}>Suivant</Button>
</>}
</div>
</CardContent></Card>
<Card><CardContent className="overflow-x-auto p-0">

View File

@@ -41,6 +41,8 @@ type ErrorDetail = {
value?: string;
};
const IMPORT_LOG_PAGE_SIZE = 200;
function parseJson<T>(str: string | null | undefined): T[] {
if (!str) return [];
try {
@@ -59,7 +61,13 @@ function StatusBadge({ imported, duplicates, errors }: { imported: number; dupli
}
export default function ImportReport() {
const { data: logs, isLoading } = trpc.importLogs.getByUser.useQuery();
const [page, setPage] = useState(0);
const { data: logsPage, isLoading } = trpc.importLogs.getByUser.useQuery({
limit: IMPORT_LOG_PAGE_SIZE,
offset: page * IMPORT_LOG_PAGE_SIZE,
});
const logs = (logsPage?.items ?? []) as ImportLog[];
const totalLogs = logsPage?.total ?? 0;
const [yearFilter, setYearFilter] = useState<string>(String(new Date().getFullYear()));
const [monthFilter, setMonthFilter] = useState<string>("all");
const [statusFilter, setStatusFilter] = useState<string>("all");
@@ -319,6 +327,14 @@ export default function ImportReport() {
)}
</div>
</div>
{totalLogs > IMPORT_LOG_PAGE_SIZE && (
<div className="mt-3 flex items-center justify-end gap-2 text-xs text-gray-500">
<span>{totalLogs.toLocaleString("fr-FR")} journaux</span>
<Button size="sm" variant="outline" className="h-8" disabled={page === 0} onClick={() => setPage((current) => current - 1)}>Précédent</Button>
<span>Page {page + 1}</span>
<Button size="sm" variant="outline" className="h-8" disabled={(page + 1) * IMPORT_LOG_PAGE_SIZE >= totalLogs} onClick={() => setPage((current) => current + 1)}>Suivant</Button>
</div>
)}
</CardContent>
</Card>

View File

@@ -1,4 +1,4 @@
import { useState, useEffect } from "react";
import { useState, useEffect, useMemo } from "react";
import { trpc } from "@/lib/trpc";
import AzureAdExportSettingsCard from "@/components/AzureAdExportSettingsCard";
import { Button } from "@/components/ui/button";
@@ -8,6 +8,7 @@ import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import { Badge } from "@/components/ui/badge";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { useAuth } from "@/_core/hooks/useAuth";
import { toast } from "sonner";
import {
Loader2, Save, Upload, FolderOpen, Mail, Play, Square, Download,
@@ -153,6 +154,8 @@ function BackupSection() {
export default function ImportSettings() {
const { user } = useAuth();
const isAdmin = user?.role === "admin";
// Chargement différé : on ne charge les statuts de services qu'après le montage
const { data: settings, isLoading } = trpc.importSettings.get.useQuery(undefined, {
staleTime: 30_000,
@@ -173,6 +176,18 @@ export default function ImportSettings() {
const testAzureConnectionMutation = trpc.importSettings.testAzureConnection.useMutation();
const testSharePointUploadMutation = trpc.importSettings.testSharePointUpload.useMutation();
const testEmailConnectionMutation = trpc.importSettings.testEmailConnection.useMutation();
const { data: emailAccounts, isLoading: emailAccountsLoading } = trpc.emailImportService.getConfiguredAccounts.useQuery(undefined, {
enabled: isAdmin,
staleTime: 30_000,
});
const [selectedEmailAccountId, setSelectedEmailAccountId] = useState("");
const selectedEmailAccountUserId = Number(selectedEmailAccountId);
const { data: selectedEmailAccountStatus } = trpc.emailImportService.accountStatus.useQuery(
{ userId: selectedEmailAccountUserId },
{ enabled: isAdmin && Number.isInteger(selectedEmailAccountUserId) && selectedEmailAccountUserId > 0 },
);
const startEmailAccountMutation = trpc.emailImportService.startAccount.useMutation();
const stopEmailAccountMutation = trpc.emailImportService.stopAccount.useMutation();
// Manual import
const [manualImportEnabled, setManualImportEnabled] = useState(true);
@@ -257,6 +272,22 @@ export default function ImportSettings() {
}
}, [settings]);
const configuredEmailAccounts = useMemo(
() => (emailAccounts ?? []).filter((account) => account.isConfigured === 1),
[emailAccounts],
);
useEffect(() => {
if (!isAdmin || configuredEmailAccounts.length === 0) {
setSelectedEmailAccountId("");
return;
}
if (!configuredEmailAccounts.some((account) => String(account.userId) === selectedEmailAccountId)) {
setSelectedEmailAccountId(String(configuredEmailAccounts[0].userId));
}
}, [isAdmin, configuredEmailAccounts, selectedEmailAccountId]);
const computedExportMode = (): "browser" | "folder" | "both" => {
if (bapExportBrowser && bapExportFolder) return "both";
if (bapExportFolder) return "folder";
@@ -537,8 +568,9 @@ export default function ImportSettings() {
/>
</div>
{emailImportEnabled && (
<div className="space-y-4 p-4 bg-green-50/50 dark:bg-green-950/10 rounded-lg border-2 border-green-200 dark:border-green-800">
<div className="space-y-4 p-4 bg-green-50/50 dark:bg-green-950/10 rounded-lg border-2 border-green-200 dark:border-green-800">
{emailImportEnabled && (
<>
{/* Mode d'authentification */}
<div className="space-y-2">
<Label className="text-base font-medium">Mode d'authentification IMAP</Label>
@@ -568,7 +600,7 @@ export default function ImportSettings() {
</div>
{emailImportAuthMode === "oauth2" && (
<div className="p-3 bg-purple-50 dark:bg-purple-950/20 rounded-lg border border-purple-200 dark:border-purple-800 text-sm text-purple-700 dark:text-purple-300">
<strong>Office 365 :</strong> Microsoft a désactivé l'auth basique pour Exchange Online. Utilisez ce mode avec les credentials Azure AD déjà configurés dans l'onglet Export.
<strong>Office 365 :</strong> Microsoft a désactivé l'auth basique pour Exchange Online. Utilisez ce mode avec les paramètres Azure AD configurés dans la carte Microsoft Azure AD au début de cette page.
</div>
)}
</div>
@@ -600,7 +632,7 @@ export default function ImportSettings() {
{emailImportAuthMode === "oauth2" && (
<div className="p-3 bg-muted/30 rounded-lg border text-sm text-muted-foreground">
<Wifi className="inline h-4 w-4 mr-1" />
En mode OAuth2, les credentials Azure AD (Tenant ID, Client ID, Client Secret) configurés dans l'onglet <strong>Export &gt; SharePoint</strong> seront utilisés automatiquement.
En mode OAuth2, les paramètres Azure AD (Tenant ID, Client ID, Client Secret) configurés dans la <strong>carte Microsoft Azure AD</strong> seront utilisés automatiquement.
</div>
)}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
@@ -741,12 +773,12 @@ export default function ImportSettings() {
const result = await startEmailServiceMutation.mutateAsync();
if (result.success) {
await utils.emailImportService.status.invalidate();
toast.success("Service démarré");
toast.success(result.message);
} else {
toast.error("Impossible de démarrer le service");
toast.error(result.message || "Impossible de démarrer le service", { duration: 8000 });
}
} catch {
toast.error("Impossible de démarrer le service");
} catch (error: any) {
toast.error(error?.message || "Impossible de démarrer le service", { duration: 8000 });
}
}}
disabled={!emailImportEnabled || startEmailServiceMutation.isPending}
@@ -757,8 +789,82 @@ export default function ImportSettings() {
</Button>
)}
</div>
<p className="text-xs text-muted-foreground">
Enregistrez les paramètres avant de démarrer. Le service ne démarre que si le test IMAP est concluant.
</p>
</>
)}
{isAdmin && (
<div className="space-y-3 border-t border-green-200 pt-4 dark:border-green-800">
<div>
<p className="text-sm font-medium">Compte source administré</p>
<p className="text-xs text-muted-foreground">
Démarrez la boîte déjà configurée, même si ses paramètres appartiennent à un autre compte applicatif. Aucun identifiant n’est affiché ni recopié.
</p>
</div>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
<select
aria-label="Compte source de réception e-mail"
className="h-10 min-w-0 flex-1 rounded-md border border-input bg-background px-3 text-sm"
value={selectedEmailAccountId}
onChange={(event) => setSelectedEmailAccountId(event.target.value)}
disabled={emailAccountsLoading || configuredEmailAccounts.length === 0}
>
{configuredEmailAccounts.length === 0 ? (
<option value="">Aucune boîte e-mail configurée</option>
) : (
configuredEmailAccounts.map((account) => (
<option key={account.userId} value={String(account.userId)}>
{account.userName || account.userEmail || `Compte #${account.userId}`}
{account.automaticEnabled === 1 ? " — réception activée" : " — réception désactivée"}
</option>
))
)}
</select>
{selectedEmailAccountStatus?.isRunning ? (
<Button
variant="destructive"
onClick={async () => {
try {
const result = await stopEmailAccountMutation.mutateAsync({ userId: selectedEmailAccountUserId });
await utils.emailImportService.accountStatus.invalidate();
toast.success(result.message);
} catch (error: any) {
toast.error(error?.message || "Impossible d’arrêter le service.");
}
}}
disabled={stopEmailAccountMutation.isPending}
>
{stopEmailAccountMutation.isPending ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <Square className="mr-2 h-4 w-4" />}
Arrêter
</Button>
) : (
<Button
onClick={async () => {
try {
const result = await startEmailAccountMutation.mutateAsync({ userId: selectedEmailAccountUserId });
if (result.success) {
await utils.emailImportService.accountStatus.invalidate();
toast.success(result.message);
} else {
toast.error(result.message, { duration: 8000 });
}
} catch (error: any) {
toast.error(error?.message || "Impossible de démarrer le service.", { duration: 8000 });
}
}}
disabled={!selectedEmailAccountUserId || startEmailAccountMutation.isPending}
className="bg-green-600 hover:bg-green-700"
>
{startEmailAccountMutation.isPending ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <Play className="mr-2 h-4 w-4" />}
Démarrer
</Button>
)}
</div>
</div>
)}
</div>
)}
</CardContent>
</Card>

View File

@@ -72,19 +72,6 @@ export default function InvoiceDetail() {
});
const handleSave = () => {
// Calculate new quality score based on filled fields
let filledFields = 0;
let totalFields = 6;
if (formData.supplierName) filledFields++;
if (formData.invoiceNumber) filledFields++;
if (formData.invoiceDate) filledFields++;
if (formData.deliveryNoteNumber) filledFields++;
if (formData.orderNumber) filledFields++;
if (formData.totalAmount) filledFields++;
const newQualityScore = Math.round((filledFields / totalFields) * 100);
// Apprentissage : enregistrer les corrections manuelles sur les champs clés
// On compare les valeurs actuelles (invoice) avec les nouvelles valeurs (formData)
if (invoice && formData.supplierName) {

View File

@@ -40,6 +40,7 @@ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/comp
import { toast } from "sonner";
import { useLocation } from "wouter";
import { matchesInvoicePeriod } from "@shared/invoicePeriod";
import { requiresBapBeforeOriginalPdfExport } from "@shared/bapExportGuard";
type SortField = "invoiceDate" | "createdAt";
type SortDir = "asc" | "desc";
@@ -359,9 +360,13 @@ export default function Invoices({ scope = "all" }: { scope?: InvoiceScope }) {
return (invoice.qualityScore || 0) === 100;
};
const isEligibleForPdfExport = (invoice: any) => {
return isEligibleForExport(invoice) && !requiresBapBeforeOriginalPdfExport(invoice);
};
const hasOnlyEligibleSelected = selectedIds.length > 0 && selectedIds.every(id => {
const invoice = invoices?.find(inv => inv.id === id);
return invoice && isEligibleForExport(invoice);
return invoice && isEligibleForPdfExport(invoice);
});
const sortedInvoices = [...(filteredInvoices || [])].sort((a, b) => {
@@ -403,7 +408,7 @@ export default function Invoices({ scope = "all" }: { scope?: InvoiceScope }) {
onClick={handleExport}
disabled={selectedIds.length === 0 || !hasOnlyEligibleSelected || exportMutation.isPending}
className="bg-blue-600 hover:bg-blue-700"
title={!hasOnlyEligibleSelected && selectedIds.length > 0 ? "Seules les factures avec un score de 100% peuvent être exportées" : ""}
title={!hasOnlyEligibleSelected && selectedIds.length > 0 ? "Les factures hors abonnement doivent être validées BAP avant l'export PDF" : ""}
>
<Download className="w-4 h-4 mr-2" />
PDF ({selectedIds.length})

View File

@@ -33,6 +33,7 @@ import {
} from "@/components/ui/table";
import { trpc } from "@/lib/trpc";
import { BAP_MIN_QUALITY_SCORE, meetsBapQualityThreshold } from "@shared/bapEligibility";
import { wasExportedBeforeBap } from "@shared/bapExportGuard";
import { Search, FileText, Download, FileSpreadsheet, Trash2, Edit, Trash, CheckCircle, CheckCircle2, ShieldCheck, RefreshCw, FolderDown, ChevronDown, ChevronRight, ChevronsUpDown, CalendarDays, ArrowUpDown, ArrowUp, ArrowDown } from "lucide-react";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
@@ -410,7 +411,6 @@ export default function InvoicesBAP() {
const isEligibleForBAPValidation = (invoice: any) => {
return (
meetsBapQualityThreshold(invoice.qualityScore) &&
invoice.exportStatus !== "exported" &&
invoice.isSubscription === 0 &&
invoice.serviceConcerne &&
invoice.typeAchat &&
@@ -547,7 +547,10 @@ export default function InvoicesBAP() {
exportExcelMutation.mutate({ invoiceIds: selectedIds });
};
const getExportStatusBadge = (status: string) => {
const getExportStatusBadge = (status: string, invoice?: any) => {
if (invoice && wasExportedBeforeBap(invoice)) {
return <Badge className="bg-amber-100 text-amber-800 hover:bg-amber-100">Exporté avant BAP</Badge>;
}
switch (status) {
case "exported":
return <Badge className="bg-green-100 text-green-800 hover:bg-green-100">Exporté</Badge>;
@@ -586,7 +589,6 @@ export default function InvoicesBAP() {
const getBAPValidationTooltip = (invoice: any): string => {
const reasons: string[] = [];
if (!meetsBapQualityThreshold(invoice.qualityScore)) reasons.push(`Score < ${BAP_MIN_QUALITY_SCORE}%`);
if (invoice.exportStatus === "exported") reasons.push("Déjà exportée");
if (invoice.isSubscription !== 0) reasons.push("Marquée comme abonnement");
if (!invoice.serviceConcerne) reasons.push("Service manquant");
if (!invoice.typeAchat) reasons.push("Type d'achat manquant");
@@ -1042,7 +1044,7 @@ export default function InvoicesBAP() {
)}
</div>
</TableCell>
<TableCell>{getExportStatusBadge(invoice.exportStatus || "not_exported")}</TableCell>
<TableCell>{getExportStatusBadge(invoice.exportStatus || "not_exported", invoice)}</TableCell>
<TableCell>
<div className="flex gap-1">
<Button

View File

@@ -677,7 +677,7 @@ export default function Settings() {
const [sftpAutoExport, setSftpAutoExport] = useState(false);
const [sftpRecipientFilter, setSftpRecipientFilter] = useState("");
const [llmLogsRetentionMonths, setLlmLogsRetentionMonths] = useState(3);
const [aiProvider, setAiProvider] = useState<"mistral" | "manus" | "gemini">("mistral");
const [aiProvider, setAiProvider] = useState<"mistral" | "manus" | "gemini" | "referentiel">("mistral");
const [mistralApiKey, setMistralApiKey] = useState("");
const [manusForgeApiKey, setManusForgeApiKey] = useState("");
const [manusForgeApiUrl, setManusForgeApiUrl] = useState("");
@@ -685,6 +685,8 @@ export default function Settings() {
const [showMistralKey, setShowMistralKey] = useState(false);
const [showManusKey, setShowManusKey] = useState(false);
const [showGeminiKey, setShowGeminiKey] = useState(false);
const [referentialEngine, setReferentialEngine] = useState<{ engineName: string | null; model: string | null } | null>(null);
const [referentialEngineError, setReferentialEngineError] = useState<string | null>(null);
useEffect(() => {
if (settings) {
@@ -722,6 +724,30 @@ export default function Settings() {
},
});
const testReferentielIaMutation = trpc.settings.testReferentielIa.useMutation({
onSuccess: (data) => {
setReferentialEngine({ engineName: data.engineName, model: data.model });
setReferentialEngineError(null);
const engine = data.engineName ? ` Moteur sélectionné : ${data.engineName}.` : "";
const model = data.model ? ` Modèle : ${data.model}.` : "";
toast.success(`Connexion au Référentiel IA réussie.${engine}${model}`);
},
onError: (error) => {
setReferentialEngine(null);
setReferentialEngineError(error.message || "Le moteur sélectionné n'a pas pu être obtenu.");
toast.error(error.message || "Connexion au Référentiel IA impossible");
},
});
const selectReferentielIa = () => {
if (testReferentielIaMutation.isPending) return;
setAiProvider("referentiel");
setReferentialEngine(null);
setReferentialEngineError(null);
// Cette demande interroge uniquement la recommandation centralisée, sans analyser de facture.
testReferentielIaMutation.mutate();
};
const testSftpMutation = trpc.sftp.testConnection.useMutation({
onSuccess: (data) => {
if (data.success) {
@@ -857,7 +883,7 @@ export default function Settings() {
<CardContent className="space-y-6 pt-6">
{/* Provider selector */}
<div className="grid grid-cols-3 gap-4">
<div className="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-4 gap-4">
<button
type="button"
onClick={() => setAiProvider("mistral")}
@@ -939,6 +965,32 @@ export default function Settings() {
<Badge className="bg-blue-500 text-white text-xs">Actif</Badge>
)}
</button>
<button
type="button"
onClick={selectReferentielIa}
className={`relative flex flex-col items-center gap-3 p-5 rounded-xl border-2 transition-all cursor-pointer ${
aiProvider === "referentiel"
? "border-violet-500 bg-violet-50 dark:bg-violet-950/20 shadow-md"
: "border-muted hover:border-violet-300 bg-background"
}`}
>
{aiProvider === "referentiel" && (
<div className="absolute top-2 right-2">
<CheckCircle className="w-5 h-5 text-violet-500" />
</div>
)}
<div className="p-3 bg-violet-100 dark:bg-violet-900/30 rounded-xl">
<Brain className="w-8 h-8 text-violet-600" />
</div>
<div className="text-center">
<p className="font-bold text-base">Référentiel IA</p>
<p className="text-xs text-muted-foreground mt-1">Choix centralisé<br/>Moteur et modèle décidés par le référentiel</p>
</div>
{aiProvider === "referentiel" && (
<Badge className="bg-violet-500 text-white text-xs">Actif</Badge>
)}
</button>
</div>
{/* Mistral config */}
@@ -1055,6 +1107,51 @@ export default function Settings() {
</div>
)}
{aiProvider === "referentiel" && (
<div className="space-y-4 p-4 bg-violet-50 dark:bg-violet-950/10 rounded-xl border border-violet-200 dark:border-violet-800">
<div className="flex items-start justify-between gap-4">
<div>
<p className="text-sm font-semibold text-violet-700 dark:text-violet-400 uppercase tracking-wide">Configuration centralisée</p>
<p className="text-sm text-violet-900 dark:text-violet-100 mt-2">
L’analyse est envoyée à la passerelle du Référentiel IA. Le moteur, le modèle, les droits et les limites d’usage sont ceux sélectionnés pour <strong>Démat. Facturation DSI</strong> dans le Référentiel.
</p>
<p className="text-xs text-violet-700 dark:text-violet-300 mt-2">
Aucune clé du Référentiel n’est affichée ni enregistrée dans ces paramètres.
</p>
</div>
<Button
type="button"
variant="outline"
className="border-violet-300 text-violet-700 hover:bg-violet-100 shrink-0"
onClick={() => testReferentielIaMutation.mutate()}
disabled={testReferentielIaMutation.isPending}
>
{testReferentielIaMutation.isPending ? <Loader2 className="w-4 h-4 animate-spin mr-2" /> : <CheckCircle className="w-4 h-4 mr-2" />}
Vérifier la connexion
</Button>
</div>
<div className="rounded-lg border border-violet-200 bg-white/70 p-3 text-sm dark:border-violet-800 dark:bg-violet-950/20" aria-live="polite">
{testReferentielIaMutation.isPending ? (
<div className="flex items-center gap-2 text-violet-800 dark:text-violet-200">
<Loader2 className="h-4 w-4 animate-spin" />
Recherche du moteur décidé par le Référentiel IA…
</div>
) : referentialEngine ? (
<div className="space-y-1 text-violet-900 dark:text-violet-100">
<p className="font-semibold">Moteur qui sera utilisé : {referentialEngine.engineName || "défini par le Référentiel IA"}</p>
<p className="text-xs text-violet-700 dark:text-violet-300">
{referentialEngine.model ? `Modèle : ${referentialEngine.model}` : "Le modèle est également déterminé par le Référentiel IA."}
</p>
</div>
) : referentialEngineError ? (
<p className="text-violet-800 dark:text-violet-200">Impossible d’obtenir le moteur. Utilisez « Vérifier la connexion » pour réessayer.</p>
) : (
<p className="text-violet-800 dark:text-violet-200">Le moteur recommandé s’affichera automatiquement après la sélection.</p>
)}
</div>
</div>
)}
</CardContent>
</Card>
@@ -1073,20 +1170,27 @@ export default function Settings() {
</div>
</CardHeader>
<CardContent className="space-y-6 pt-6">
<div className="space-y-3">
<Label htmlFor="llmModel" className="text-base font-semibold">Modèle IA (override)</Label>
<Input
id="llmModel"
value={llmModel}
onChange={(e) => setLlmModel(e.target.value)}
placeholder="mistral-large-latest"
className="h-11"
/>
<p className="text-sm text-muted-foreground flex items-center gap-2">
<AlertCircle className="w-4 h-4" />
Nom du modèle Mistral à utiliser pour l'extraction
</p>
</div>
{aiProvider === "referentiel" ? (
<div className="space-y-2 p-4 rounded-lg bg-violet-50 dark:bg-violet-950/10 border border-violet-200 dark:border-violet-800">
<p className="font-medium text-violet-900 dark:text-violet-100">Modèle décidé par le Référentiel IA</p>
<p className="text-sm text-violet-700 dark:text-violet-300">La surcharge locale est volontairement désactivée pour garantir un moteur unique, piloté et tracé au niveau Santinova.</p>
</div>
) : (
<div className="space-y-3">
<Label htmlFor="llmModel" className="text-base font-semibold">Modèle IA (override)</Label>
<Input
id="llmModel"
value={llmModel}
onChange={(e) => setLlmModel(e.target.value)}
placeholder="mistral-large-latest"
className="h-11"
/>
<p className="text-sm text-muted-foreground flex items-center gap-2">
<AlertCircle className="w-4 h-4" />
Nom du modèle à utiliser pour l'extraction directe
</p>
</div>
)}
<div className="space-y-3">
<Label htmlFor="llmLogsRetentionMonths" className="text-base font-semibold">

View File

@@ -17,6 +17,7 @@ import {
} from "lucide-react";
import { toast } from "sonner";
import { useLocation } from "wouter";
import { parseImportResultSummary } from "@shared/importResultSummary";
interface UploadedFile {
name: string;
@@ -24,6 +25,9 @@ interface UploadedFile {
status: "pending" | "processing" | "completed" | "error";
progress?: string;
invoicesDetected?: number;
importedCount?: number;
duplicateCount?: number;
errorCount?: number;
}
export default function Upload() {
@@ -66,18 +70,22 @@ export default function Upload() {
prev.map((f) => {
if (f.sourceFileId !== sf.id) return f;
if (f.status === sf.processingStatus) return f;
const summary = parseImportResultSummary(sf.processingProgress);
return {
...f,
status: (sf.processingStatus ?? "pending") as UploadedFile["status"],
progress: sf.processingProgress ?? undefined,
invoicesDetected: sf.totalInvoicesDetected ?? undefined,
importedCount: summary?.importedCount,
duplicateCount: summary?.duplicateCount,
errorCount: summary?.errorCount,
};
})
);
});
}, [sourceFiles]);
const uploadFile = async (file: File): Promise<number | null> => {
const uploadFile = async (file: File): Promise<{ sourceFileId: number; reprocessed: boolean } | null> => {
return new Promise((resolve) => {
const reader = new FileReader();
reader.onload = async () => {
@@ -87,7 +95,7 @@ export default function Upload() {
fileName: file.name,
fileData: base64!,
});
resolve(data.sourceFileId);
resolve(data);
} catch (err: any) {
toast.error(`Erreur pour ${file.name} : ${err.message || "Erreur inconnue"}`);
resolve(null);
@@ -110,18 +118,27 @@ export default function Upload() {
setIsUploading(true);
toast.info(`Envoi de ${pdfFiles.length} fichier(s)...`);
let acceptedFiles = 0;
for (const file of pdfFiles) {
const sourceFileId = await uploadFile(file);
if (sourceFileId !== null) {
const upload = await uploadFile(file);
if (upload !== null) {
acceptedFiles++;
setUploadedFiles((prev) => [
...prev,
{ name: file.name, sourceFileId, status: "processing" },
{ name: file.name, sourceFileId: upload.sourceFileId, status: "processing" },
]);
if (upload.reprocessed) {
toast.info(`${file.name} : source existante retrouvée, nouvelle analyse lancée.`);
}
}
}
setIsUploading(false);
toast.success(`${pdfFiles.length} fichier(s) envoyé(s) — traitement en cours`);
if (acceptedFiles > 0) {
toast.success(`${acceptedFiles} fichier(s) envoyé(s) — traitement en cours`);
} else {
toast.warning("Aucun fichier n’a été lancé : consultez le message d’erreur affiché.");
}
};
const handleDragOver = useCallback((e: React.DragEvent) => {
@@ -156,6 +173,10 @@ export default function Upload() {
const processingCount = uploadedFiles.filter(
(f) => f.status === "processing" || f.status === "pending"
).length;
const importedInvoiceCount = uploadedFiles.reduce(
(total, file) => total + (file.importedCount ?? 0),
0,
);
return (
<DashboardLayout>
@@ -273,8 +294,11 @@ export default function Upload() {
<div>
<CardTitle>Fichiers importés</CardTitle>
<CardDescription>
{completedCount > 0 && (
<span className="text-green-600 font-medium">{completedCount} terminé(s) </span>
{completedCount > 0 && importedInvoiceCount > 0 && (
<span className="text-green-600 font-medium">{importedInvoiceCount} facture(s) créée(s) </span>
)}
{completedCount > 0 && importedInvoiceCount === 0 && (
<span className="text-amber-700 font-medium">{completedCount} terminé(s), aucune facture créée </span>
)}
{processingCount > 0 && (
<span className="text-blue-600 font-medium">{processingCount} en cours </span>
@@ -285,7 +309,7 @@ export default function Upload() {
</CardDescription>
</div>
<div className="flex gap-2">
{completedCount > 0 && (
{importedInvoiceCount > 0 && (
<Button
size="sm"
onClick={() => setLocation("/invoices")}
@@ -323,10 +347,16 @@ export default function Upload() {
{f.status === "processing" && (
<p className="text-xs text-muted-foreground">{f.progress || "Traitement en cours..."}</p>
)}
{f.status === "completed" && f.invoicesDetected !== undefined && (
<p className="text-xs text-green-600">
{f.invoicesDetected} facture(s) détectée(s)
</p>
{f.status === "completed" && f.importedCount !== undefined && (
f.importedCount > 0 ? (
<p className="text-xs text-green-600">
{f.importedCount} facture(s) créée(s)
</p>
) : (
<p className="text-xs text-amber-700">
Aucune facture créée{f.duplicateCount ? ` : ${f.duplicateCount} doublon(s) ou facture(s) supprimée(s)` : ""}
</p>
)
)}
{f.status === "error" && (
<p className="text-xs text-red-500">{f.progress || "Erreur de traitement"}</p>

View File

@@ -7,6 +7,7 @@ import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { Badge } from "@/components/ui/badge";
import { isValidWebPortalUrl, normalizeWebPortalUrl } from "@shared/webPortalUrl";
import {
Dialog,
DialogContent,
@@ -24,7 +25,7 @@ import {
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { Globe, Plus, Pencil, Trash2, Key, CheckCircle, XCircle, Clock, RefreshCw } from "lucide-react";
import { Globe, Plus, Pencil, Trash2, Key, CheckCircle, XCircle, Clock, RefreshCw, Play } from "lucide-react";
import { toast } from "sonner";
const CONNECTOR_TYPES = [
@@ -84,6 +85,7 @@ export default function WebImportSources() {
const [showForm, setShowForm] = useState(false);
const [editSource, setEditSource] = useState<Source | null>(null);
const [deleteId, setDeleteId] = useState<number | null>(null);
const [manualImportId, setManualImportId] = useState<number | null>(null);
const [showToken, setShowToken] = useState<number | null>(null);
const [form, setForm] = useState<FormData>(emptyForm);
@@ -123,6 +125,21 @@ export default function WebImportSources() {
onError: (e) => toast.error("Erreur", { description: e.message }),
});
const runManualMutation = trpc.webImportSources.runManual.useMutation({
onSuccess: (result) => {
toast.success("Import manuel terminé", {
description: `${result.imported} facture(s) importée(s), ${result.duplicates} doublon(s) ignoré(s).`,
});
setManualImportId(null);
refetch();
},
onError: (e) => {
toast.error("Import manuel en erreur", { description: e.message });
setManualImportId(null);
refetch();
},
});
function openCreate() {
setForm(emptyForm);
setEditSource(null);
@@ -158,12 +175,20 @@ export default function WebImportSources() {
toast.error("Champs requis", { description: "Nom, identifiant et mot de passe sont obligatoires." });
return;
}
const portalUrl = normalizeWebPortalUrl(form.portalUrl);
if (!isValidWebPortalUrl(portalUrl)) {
toast.error("URL de portail invalide", {
description: "Saisissez une adresse HTTP(S), par exemple https://espaceclient.exemple.fr.",
});
return;
}
if (editSource) {
const updateData: any = { id: editSource.id, ...form };
const updateData: any = { id: editSource.id, ...form, portalUrl };
if (!form.loginPassword) delete updateData.loginPassword; // Ne pas écraser si vide
updateMutation.mutate(updateData);
} else {
createMutation.mutate(form);
createMutation.mutate({ ...form, portalUrl });
}
}
@@ -244,6 +269,21 @@ export default function WebImportSources() {
</div>
</div>
<div className="flex items-center gap-2 shrink-0">
<Button
variant="outline"
size="sm"
className="gap-1 text-xs"
disabled={source.connectorType !== "sfr" || runManualMutation.isPending}
title={source.connectorType === "sfr" ? "Lancer une lecture ponctuelle" : "Ce connecteur ne dispose pas encore d’un moteur d’import manuel"}
onClick={() => setManualImportId(source.id)}
>
{runManualMutation.isPending && manualImportId === source.id ? (
<RefreshCw className="w-3 h-3 animate-spin" />
) : (
<Play className="w-3 h-3" />
)}
Importer maintenant
</Button>
<Button
variant="outline"
size="sm"
@@ -316,6 +356,7 @@ export default function WebImportSources() {
onChange={e => setForm(f => ({ ...f, portalUrl: e.target.value }))}
placeholder="https://..."
/>
<p className="mt-1 text-xs text-muted-foreground">Le préfixe https:// est ajouté automatiquement si nécessaire.</p>
</div>
<div>
<Label>Identifiant (email ou login)</Label>
@@ -378,6 +419,26 @@ export default function WebImportSources() {
</DialogContent>
</Dialog>
<AlertDialog open={manualImportId !== null} onOpenChange={(open) => { if (!open && !runManualMutation.isPending) setManualImportId(null); }}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Lancer l’import manuel ?</AlertDialogTitle>
<AlertDialogDescription>
L’application se connectera ponctuellement à l’espace client SFR, téléchargera les nouvelles factures puis les analysera via le flux normal. Cette action ne crée aucune planification automatique.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={runManualMutation.isPending}>Annuler</AlertDialogCancel>
<AlertDialogAction
disabled={runManualMutation.isPending}
onClick={() => manualImportId !== null && runManualMutation.mutate({ id: manualImportId })}
>
{runManualMutation.isPending ? "Import en cours…" : "Lancer l’import"}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{/* Confirmation suppression */}
<AlertDialog open={deleteId !== null} onOpenChange={(open) => { if (!open) setDeleteId(null); }}>
<AlertDialogContent>

View File

@@ -58,6 +58,10 @@ services:
DATABASE_URL: mysql://root:demat_facturation_db_pass_2026@demat-facturation-db:3306/gestion_facturation
JWT_SECRET: demat-facturation-jwt-secret-recette-2026
MISTRAL_API_KEY: ${MISTRAL_API_KEY:-}
# Passerelle centralisée : le jeton reste dans l'environnement serveur.
REFERENTIEL_IA_URL: ${REFERENTIEL_IA_URL:-https://referentiel-ia-santinova.recette.santinova-soft.org}
REFERENTIEL_IA_TOKEN: ${REFERENTIEL_IA_TOKEN:-}
REFERENTIEL_IA_TOKEN_FILE: /run/secrets/referentiel_ia_token
UPLOAD_DIR: /app/uploads
# Chemin unique utilisé par le serveur et vérifié au démarrage.
STORAGE_BASE_PATH: /app/storage
@@ -78,6 +82,10 @@ services:
target: /app/storage
volume:
nocopy: true
secrets:
- source: referentiel_ia_token
target: referentiel_ia_token
mode: 0400
# L’application doit utiliser eth0 sur le réseau de données ; l’ordre évite
# toute inversion d’interface lors d’une recréation Docker multi-réseaux.
networks:
@@ -87,7 +95,7 @@ services:
priority: 100
labels:
- "traefik.enable=true"
- "traefik.http.routers.demat-facturation.rule=Host(`${APP_DOMAIN:-demat-facturation.recette.santinova-soft.org}`)"
- "traefik.http.routers.demat-facturation.rule=Host(`demat-facturation-santinova.recette.santinova-soft.org`)"
- "traefik.http.routers.demat-facturation.entrypoints=websecure"
- "traefik.http.routers.demat-facturation.tls.certresolver=letsencrypt"
- "traefik.http.services.demat-facturation.loadbalancer.server.port=3000"
@@ -107,6 +115,10 @@ volumes:
external: true
name: ${STORAGE_VOLUME_NAME:-demat-facturation-dsi_demat_facturation_storage}
secrets:
referentiel_ia_token:
file: /opt/manus-secrets/demat-facturation-referentiel-ia-token
networks:
demat-facturation-internal:
driver: bridge

View File

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

View File

@@ -0,0 +1,11 @@
-- Rattrapage additif : certains environnements historiques possèdent le code
-- de blacklist sans avoir exécuté la migration 0035 correspondante.
CREATE TABLE IF NOT EXISTS `deletedInvoices` (
`id` int AUTO_INCREMENT NOT NULL,
`userId` int NOT NULL,
`invoiceNumber` varchar(100) NOT NULL,
`totalAmount` varchar(50),
`supplierName` varchar(255),
`deletedAt` timestamp NOT NULL DEFAULT (now()),
CONSTRAINT `deletedInvoices_id` PRIMARY KEY(`id`)
);

File diff suppressed because it is too large Load Diff

View File

@@ -288,6 +288,20 @@
"when": 1788942350737,
"tag": "0040_sudden_flatman",
"breakpoints": true
},
{
"idx": 41,
"version": "5",
"when": 1790785209695,
"tag": "0041_acoustic_microchip",
"breakpoints": true
},
{
"idx": 42,
"version": "5",
"when": 1790960584104,
"tag": "0042_deleted_invoices_blacklist",
"breakpoints": true
}
]
}

View File

@@ -153,7 +153,7 @@ 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", "gemini"]).default("mistral").notNull(), // AI provider: 'mistral', 'manus' or 'gemini'
aiProvider: mysqlEnum("aiProvider", ["mistral", "manus", "gemini", "referentiel"]).default("mistral").notNull(), // Moteur direct ou sélection centralisée par le Référentiel IA
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)
@@ -201,7 +201,7 @@ export const importSettings = mysqlTable("importSettings", {
azureSecretExpiresAt: timestamp("azureSecretExpiresAt"), // Azure AD Client Secret expiration date
// AI Engine settings
aiProvider: mysqlEnum("aiProvider", ["mistral", "manus", "gemini"]).default("mistral").notNull(), // AI provider for invoice extraction
aiProvider: mysqlEnum("aiProvider", ["mistral", "manus", "gemini", "referentiel"]).default("mistral").notNull(), // Moteur direct ou sélection centralisée par le Référentiel IA
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)

View File

@@ -85,6 +85,7 @@
"pdf-parse": "^2.4.5",
"pdf2json": "^4.0.2",
"pdfjs-dist": "^5.4.624",
"playwright-core": "^1.63.0",
"react": "^19.2.1",
"react-day-picker": "^9.11.1",
"react-dom": "^19.2.1",

10
pnpm-lock.yaml generated
View File

@@ -229,6 +229,9 @@ importers:
pdfjs-dist:
specifier: ^5.4.624
version: 5.4.624
playwright-core:
specifier: ^1.63.0
version: 1.63.0
react:
specifier: ^19.2.1
version: 19.2.1
@@ -4033,6 +4036,11 @@ packages:
resolution: {integrity: sha512-r34yH/GlQpKZbU1BvFFqOjhISRo1MNx1tWYsYvmj6KIRHSPMT2+yHOEb1SG6NMvRoHRF0a07kCOox/9yakl1vg==}
hasBin: true
playwright-core@1.63.0:
resolution: {integrity: sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg==}
engines: {node: '>=20'}
hasBin: true
pnpm@10.18.0:
resolution: {integrity: sha512-6AT4ifHOzEDVctsITuw+SIFzn43sacD/ENLRvv+aTjCTg7ontbdQBZ1/TBSVNbbNDSyx7Trrc5I5pChKaPQM+g==}
engines: {node: '>=18.12'}
@@ -8601,6 +8609,8 @@ snapshots:
sonic-boom: 4.2.1
thread-stream: 4.2.0
playwright-core@1.63.0: {}
pnpm@10.18.0: {}
postcss-selector-parser@6.0.10:

View File

@@ -23,7 +23,7 @@ Contenu de `config.json` :
{
"sfrLogin": "votre-login@sfr.fr",
"sfrPassword": "votre-mot-de-passe",
"appUrl": "https://demat-facturation.santinova-soft.org",
"appUrl": "https://demat-facturation-santinova.santinova-soft.org",
"apiToken": "votre-token-api-copié-depuis-lappli",
"downloadDir": "/tmp/sfr-invoices",
"processedFile": "/tmp/sfr-processed.json"
@@ -34,7 +34,7 @@ Ou utiliser des variables d'environnement :
```bash
export SFR_LOGIN=votre-login@sfr.fr
export SFR_PASSWORD=votre-mot-de-passe
export APP_URL=https://demat-facturation.santinova-soft.org
export APP_URL=https://demat-facturation-santinova.santinova-soft.org
export API_TOKEN=votre-token-api
```

View File

@@ -33,7 +33,7 @@ const CONFIG = {
login: process.env.SFR_LOGIN || '',
password: process.env.SFR_PASSWORD || '',
// URL de l'application de dématérialisation
appUrl: process.env.APP_URL || 'https://demat-facturation.santinova-soft.org',
appUrl: process.env.APP_URL || 'https://demat-facturation-santinova.santinova-soft.org',
// Token API de la source web (récupéré depuis l'interface Connecteurs web)
apiToken: process.env.API_TOKEN || '',
// Dossier temporaire pour les PDFs téléchargés

View File

@@ -1,3 +1,21 @@
import { readFileSync } from "node:fs";
/** Lit un secret monté par Docker, sans jamais exposer son contenu aux logs. */
export function readSecretFile(path: string | undefined): string {
if (!path) return "";
try {
return readFileSync(path, "utf8").trim();
} catch {
return "";
}
}
/** Préfère un secret explicite, puis le secret Docker si la variable est absente ou vide. */
export function resolveServerSecret(value: string | undefined, secretPath: string | undefined): string {
const environmentValue = value?.trim();
return environmentValue || readSecretFile(secretPath);
}
export const ENV = {
appId: process.env.VITE_APP_ID ?? "",
cookieSecret: process.env.JWT_SECRET ?? "",
@@ -9,4 +27,9 @@ export const ENV = {
forgeApiKey: process.env.BUILT_IN_FORGE_API_KEY ?? "",
mistralApiKey: process.env.MISTRAL_API_KEY ?? "",
geminiApiKey: process.env.GEMINI_API_KEY ?? "",
referentielIaUrl: process.env.REFERENTIEL_IA_URL ?? "",
referentielIaToken: resolveServerSecret(
process.env.REFERENTIEL_IA_TOKEN,
process.env.REFERENTIEL_IA_TOKEN_FILE,
),
};

View File

@@ -19,6 +19,7 @@ import { handleAzureCallback, isAzureAdConfigured, generateToken, verifyToken }
import { createDatabaseBackup } from "../databaseBackup";
import { generateStorageKey, localStorageDelete, localStoragePut } from "../localStorage";
import { calculateFileSha256 } from "../fileFingerprint";
import { withCalculatedInvoiceQuality } from "@shared/invoiceQuality";
const MAX_WEB_IMPORT_BYTES = 20 * 1024 * 1024;
@@ -306,7 +307,7 @@ async function startServer() {
return;
}
const { getWebImportSourceByToken, createInvoice, findDuplicateInvoice, isInvoiceBlacklisted, updateWebImportSourceStatus, createSourceFile, getSourceFileByContentHash } = await import('../db');
const { getWebImportSourceByToken, createInvoice, findDuplicateInvoice, updateWebImportSourceStatus, createSourceFile, getSourceFileByContentHash, getLlmFieldsConfigByUser } = await import('../db');
const source = await getWebImportSourceByToken(apiToken);
if (!source) {
res.status(401).json({ error: "Token invalide" });
@@ -348,6 +349,7 @@ async function startServer() {
throw error;
}
const userSettings = await getUserSettings(source.userId);
const qualityFieldsConfig = await getLlmFieldsConfigByUser(source.userId);
const aiSettings = {
aiProvider: userSettings?.aiProvider || "manus",
mistralApiKey: userSettings?.mistralApiKey || undefined,
@@ -360,11 +362,10 @@ async function startServer() {
let imported = 0;
let duplicates = 0;
for (const inv of extractResult.invoices || []) {
const blacklisted = await isInvoiceBlacklisted(inv.invoiceNumber || null, source.userId);
if (blacklisted) { duplicates++; continue; }
const dup = await findDuplicateInvoice(inv.invoiceNumber || null, String(inv.totalAmount ?? ''), source.userId);
if (dup) { duplicates++; continue; }
await createInvoice({ ...inv, userId: source.userId, sourceFileId: sourceFile.id } as any);
const invoiceWithQuality = withCalculatedInvoiceQuality(inv, qualityFieldsConfig);
await createInvoice({ ...invoiceWithQuality, userId: source.userId, sourceFileId: sourceFile.id } as any);
imported++;
}
await updateWebImportSourceStatus(source.id, 'success', imported, true);

View File

@@ -1,4 +1,5 @@
import { ENV } from "./env";
import { invokeReferentialIa } from "../referentialIa";
export type Role = "system" | "user" | "assistant" | "tool" | "function";
@@ -97,8 +98,8 @@ export type InvokeResult = {
};
};
export type LlmProvider = "mistral" | "manus" | "gemini";
export type LlmTransport = "mistral-api" | "manus-forge" | "gemini-api";
export type LlmProvider = "mistral" | "manus" | "gemini" | "referentiel";
export type LlmTransport = "mistral-api" | "manus-forge" | "gemini-api" | "referentiel-gateway";
export type UserLlmSettings = {
aiProvider?: string | null;
@@ -344,6 +345,9 @@ export function resolveLlmTrace(settings?: UserLlmSettings): ResolvedLlmTrace {
if (provider === "gemini") {
return { provider: "gemini", model: "gemini-2.0-flash", transport: "gemini-api" };
}
if (provider === "referentiel") {
return { provider: "referentiel", model: "referentiel-ia", transport: "referentiel-gateway" };
}
return { provider: "mistral", model: "mistral-large-latest", transport: "mistral-api" };
}
@@ -355,14 +359,17 @@ export async function invokeLLMWithUserSettings(
params: InvokeParams,
userSettings?: UserLlmSettings
): Promise<ResolvedLlmInvocation> {
const trace = resolveLlmTrace(userSettings);
if (trace.provider === "referentiel") {
return { response: await invokeReferentialIa(params), trace };
}
const apiKey = getApiKeyWithSettings(userSettings);
if (!apiKey || apiKey.trim().length === 0) {
throw new Error("Aucune clé API configurée pour le moteur IA sélectionné");
}
const trace = resolveLlmTrace(userSettings);
const provider = trace.provider;
const {
messages,
tools,

View File

@@ -0,0 +1,40 @@
import { describe, expect, it } from "vitest";
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { requiresBapBeforeOriginalPdfExport } from "../shared/bapExportGuard";
describe("garde-fou d’export avant BAP", () => {
it("bloque l’export PDF original d’une facture hors abonnement non validée BAP", () => {
expect(requiresBapBeforeOriginalPdfExport({ isSubscription: 0, bapValidated: 0 })).toBe(true);
});
it("autorise les abonnements et les factures déjà validées BAP", () => {
expect(requiresBapBeforeOriginalPdfExport({ isSubscription: 1, bapValidated: 0 })).toBe(false);
expect(requiresBapBeforeOriginalPdfExport({ isSubscription: 0, bapValidated: 1 })).toBe(false);
});
it("applique le garde-fou aux deux exports PDF métier côté serveur", () => {
const routerSource = readFileSync(join(process.cwd(), "server", "routers.ts"), "utf8");
const exportPdfSection = routerSource.slice(
routerSource.indexOf("exportToPdf: protectedProcedure"),
routerSource.indexOf("exportInvoices: protectedProcedure"),
);
const exportInvoicesSection = routerSource.slice(
routerSource.indexOf("exportInvoices: protectedProcedure"),
routerSource.indexOf("// ============= IMPORT LOGS ROUTES"),
);
expect(exportPdfSection).toContain("requiresBapBeforeOriginalPdfExport");
expect(exportInvoicesSection).toContain("requiresBapBeforeOriginalPdfExport");
});
it("clarifie les exports historiques et autorise leur régularisation BAP dans l’interface", () => {
const invoicesPage = readFileSync(join(process.cwd(), "client", "src", "pages", "Invoices.tsx"), "utf8");
const bapPage = readFileSync(join(process.cwd(), "client", "src", "pages", "InvoicesBAP.tsx"), "utf8");
expect(invoicesPage).toContain("requiresBapBeforeOriginalPdfExport");
expect(bapPage).toContain("Exporté avant BAP");
expect(bapPage).toContain("wasExportedBeforeBap");
expect(bapPage).not.toContain('invoice.exportStatus !== "exported"');
});
});

View File

@@ -1,4 +1,4 @@
import { eq, and, desc, sql, inArray, getTableColumns } from "drizzle-orm";
import { eq, and, desc, sql, inArray, getTableColumns, count, lt } from "drizzle-orm";
import { drizzle } from "drizzle-orm/mysql2";
import {
InsertUser,
@@ -231,6 +231,17 @@ export async function getSourceFileById(id: number): Promise<SourceFile | undefi
return result[0];
}
/** Nombre de factures encore rattachées à une source PDF. */
export async function countInvoicesBySourceFileId(sourceFileId: number): Promise<number> {
const db = await getDb();
if (!db) return 0;
const result = await db
.select({ value: count() })
.from(invoices)
.where(eq(invoices.sourceFileId, sourceFileId));
return Number(result[0]?.value ?? 0);
}
export async function updateSourceFile(id: number, data: Partial<SourceFile>) {
const db = await getDb();
if (!db) throw new Error("Database not available");
@@ -291,12 +302,26 @@ export async function deleteInvoice(id: number) {
// Enregistrer dans la blacklist avant suppression
const invoice = await db.select().from(invoices).where(eq(invoices.id, id)).limit(1);
if (invoice[0] && invoice[0].invoiceNumber) {
await db.insert(deletedInvoices).values({
userId: invoice[0].userId,
invoiceNumber: invoice[0].invoiceNumber,
totalAmount: invoice[0].totalAmount ?? undefined,
supplierName: invoice[0].supplierName ?? undefined,
}).onDuplicateKeyUpdate({ set: { deletedAt: new Date() } });
const blacklistEntry = await db.select({ id: deletedInvoices.id })
.from(deletedInvoices)
.where(and(
eq(deletedInvoices.userId, invoice[0].userId),
eq(deletedInvoices.invoiceNumber, invoice[0].invoiceNumber),
))
.limit(1);
if (blacklistEntry[0]) {
await db.update(deletedInvoices)
.set({ deletedAt: new Date() })
.where(eq(deletedInvoices.id, blacklistEntry[0].id));
} else {
await db.insert(deletedInvoices).values({
userId: invoice[0].userId,
invoiceNumber: invoice[0].invoiceNumber,
totalAmount: invoice[0].totalAmount ?? undefined,
supplierName: invoice[0].supplierName ?? undefined,
});
}
}
await db.delete(invoices).where(eq(invoices.id, id));
}
@@ -546,10 +571,15 @@ export async function getAllImportLogs(): Promise<ImportLog[]> {
* les paramètres sensibles de messagerie. L'administrateur peut ainsi relier
* chaque import à son compte, fichier source et déclencheur.
*/
export async function getImportLogsWithDetailsByUser(userId: number) {
export type ImportLogPageOptions = {
limit: number;
offset: number;
};
export async function getImportLogsWithDetailsByUser(userId: number, options: ImportLogPageOptions) {
const db = await getDb();
if (!db) return [];
return db.select({
if (!db) return { items: [], total: 0 };
const items = await db.select({
...getTableColumns(importLogs),
userName: users.name,
userEmail: users.email,
@@ -560,13 +590,17 @@ export async function getImportLogsWithDetailsByUser(userId: number) {
.leftJoin(users, eq(importLogs.userId, users.id))
.leftJoin(sourceFiles, eq(importLogs.sourceFileId, sourceFiles.id))
.where(eq(importLogs.userId, userId))
.orderBy(desc(importLogs.importedAt));
.orderBy(desc(importLogs.importedAt))
.limit(options.limit)
.offset(options.offset);
const [total] = await db.select({ total: count() }).from(importLogs).where(eq(importLogs.userId, userId));
return { items, total: Number(total?.total ?? 0) };
}
export async function getAllImportLogsWithDetails() {
export async function getAllImportLogsWithDetails(options: ImportLogPageOptions) {
const db = await getDb();
if (!db) return [];
return db.select({
if (!db) return { items: [], total: 0 };
const items = await db.select({
...getTableColumns(importLogs),
userName: users.name,
userEmail: users.email,
@@ -576,7 +610,11 @@ export async function getAllImportLogsWithDetails() {
.from(importLogs)
.leftJoin(users, eq(importLogs.userId, users.id))
.leftJoin(sourceFiles, eq(importLogs.sourceFileId, sourceFiles.id))
.orderBy(desc(importLogs.importedAt));
.orderBy(desc(importLogs.importedAt))
.limit(options.limit)
.offset(options.offset);
const [total] = await db.select({ total: count() }).from(importLogs);
return { items, total: Number(total?.total ?? 0) };
}
export async function deleteAllImportLogs(userId: number | null): Promise<void> {
@@ -590,6 +628,25 @@ export async function deleteAllImportLogs(userId: number | null): Promise<void>
}
}
/** Compte les seuls journaux d'import qui seront concernés par une purge de rétention. */
export async function countImportLogsBefore(cutoff: Date): Promise<number> {
const db = await getDb();
if (!db) return 0;
const [result] = await db.select({ total: count() }).from(importLogs).where(lt(importLogs.importedAt, cutoff));
return Number(result?.total ?? 0);
}
/**
* Supprime uniquement les journaux d'import antérieurs à la borne donnée.
* Les factures, fichiers source et PDF ne sont jamais modifiés.
*/
export async function deleteImportLogsBefore(cutoff: Date): Promise<number> {
const db = await getDb();
if (!db) throw new Error("Database not available");
const result = await db.delete(importLogs).where(lt(importLogs.importedAt, cutoff));
return Number(result[0]?.affectedRows ?? 0);
}
// ============= LLM LOG OPERATIONS =============
export async function createLlmLog(data: InsertLlmLog): Promise<LlmLog> {

View File

@@ -0,0 +1,27 @@
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import { describe, expect, it } from "vitest";
const source = (path: string) => readFileSync(resolve(process.cwd(), path), "utf8");
describe("réimport d'une facture supprimée", () => {
it("ne traite plus une entrée deletedInvoices comme un doublon", () => {
for (const path of [
"server/routers.ts",
"server/emailImportService.ts",
"server/_core/index.ts",
]) {
expect(source(path)).not.toContain("isInvoiceBlacklisted");
}
});
it("conserve uniquement la détection des doublons réellement présents", () => {
for (const path of [
"server/routers.ts",
"server/emailImportService.ts",
"server/_core/index.ts",
]) {
expect(source(path)).toContain("findDuplicateInvoice");
}
});
});

View File

@@ -35,4 +35,13 @@ describe("routage et persistance de déploiement", () => {
expect(guardScript).toContain('REQUIRE_STORAGE_VOLUME:-false');
expect(guardScript).toContain('mkdir -p "$storage_path"');
});
it("injecte uniquement les secrets serveur de la passerelle Référentiel IA", () => {
const composeFile = readFileSync(join(process.cwd(), "docker-compose.yml"), "utf8");
expect(composeFile).toContain("REFERENTIEL_IA_URL: ${REFERENTIEL_IA_URL:-https://referentiel-ia-santinova.recette.santinova-soft.org}");
expect(composeFile).toContain("REFERENTIEL_IA_TOKEN: ${REFERENTIEL_IA_TOKEN:-}");
expect(composeFile).toContain("REFERENTIEL_IA_TOKEN_FILE: /run/secrets/referentiel_ia_token");
expect(composeFile).toContain("file: /opt/manus-secrets/demat-facturation-referentiel-ia-token");
});
});

View File

@@ -0,0 +1,14 @@
import { createHash } from "node:crypto";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import { describe, expect, it } from "vitest";
const migrationPath = resolve(process.cwd(), "drizzle/0036_broken_rattler.sql");
const recordedHash = "3c636f379884a82b48aea91af65532affc0bdd8f6c56fdac35e583c31ce3a8e3";
describe("historique Drizzle", () => {
it("conserve l'empreinte de la migration 0036 déjà enregistrée", () => {
const hash = createHash("sha256").update(readFileSync(migrationPath)).digest("hex");
expect(hash).toBe(recordedHash);
});
});

View File

@@ -1,7 +1,11 @@
import { describe, expect, it } from "vitest";
import { describe, expect, it, vi } from "vitest";
import {
createEmailImportAccountController,
createImapFlowOptions,
type EmailImportConfig,
isEmailImportServiceRunning,
startConfiguredEmailImportService,
stopEmailImportService,
validateEmailImportConfiguration,
} from "./emailImportService";
@@ -76,3 +80,71 @@ describe("validateEmailImportConfiguration", () => {
expect(error).toBe("Configuration IMAP incomplète");
});
});
describe("startConfiguredEmailImportService", () => {
it("ne crée aucune planification si le test IMAP préalable échoue", async () => {
const userId = 901;
stopEmailImportService(userId);
const result = await startConfiguredEmailImportService(
{ ...baseConfig, userId, authMode: "basic" },
5,
async () => ({
success: false,
message: "Authentification IMAP refusée.",
}),
);
expect(result).toEqual({
success: false,
message: "Authentification IMAP refusée.",
});
expect(isEmailImportServiceRunning(userId)).toBe(false);
});
it("démarre après un test IMAP concluant et exécute le contrôle initial", async () => {
const userId = 902;
const runEmailCheck = vi.fn(async () => undefined);
try {
const result = await startConfiguredEmailImportService(
{ ...baseConfig, userId, authMode: "basic" },
5,
async () => ({ success: true, message: "Connexion IMAP réussie." }),
runEmailCheck,
);
expect(result).toEqual({
success: true,
message: "Service de réception e-mail démarré",
});
expect(isEmailImportServiceRunning(userId)).toBe(true);
expect(runEmailCheck).toHaveBeenCalledWith(expect.objectContaining({ userId }));
} finally {
stopEmailImportService(userId);
}
});
});
describe("createEmailImportAccountController", () => {
it("cible exclusivement le compte sélectionné par un administrateur", async () => {
const start = vi.fn(async () => ({ success: true, message: "Service de réception e-mail démarré" }));
const stop = vi.fn();
const isRunning = vi.fn(() => true);
const controller = createEmailImportAccountController({ start, stop, isRunning });
await expect(controller.startAccount(42)).resolves.toEqual({
success: true,
message: "Service de réception e-mail démarré",
});
expect(start).toHaveBeenCalledWith(42);
expect(controller.stopAccount(42)).toEqual({
success: true,
message: "Service de réception e-mail arrêté",
});
expect(stop).toHaveBeenCalledWith(42);
expect(controller.accountStatus(42)).toEqual({ isRunning: true });
expect(isRunning).toHaveBeenCalledWith(42);
});
});

View File

@@ -5,9 +5,9 @@ import {
createSourceFile,
updateSourceFile,
getUserSettings,
getLlmFieldsConfigByUser,
getSourceFileByContentHash,
findDuplicateInvoice,
isInvoiceBlacklisted,
createInvoice,
createImportLog,
} from "./db";
@@ -17,6 +17,7 @@ import { calculateFileSha256 } from "./fileFingerprint";
import { sendImportNotification } from "./notificationService";
import { getOffice365ImapToken } from "./office365OAuth";
import { applyAutomationRules } from "./automationEngine";
import { withCalculatedInvoiceQuality } from "@shared/invoiceQuality";
export interface EmailImportConfig {
userId: number;
@@ -34,6 +35,17 @@ export interface EmailImportConfig {
export type EmailImportTrigger = "manual" | "automatic";
export type EmailImportServiceStartResult = {
success: boolean;
message: string;
};
type EmailConnectionTester = (
config: EmailImportConfig,
) => Promise<{ success: boolean; message: string }>;
type EmailCheckRunner = (config: EmailImportConfig) => Promise<void>;
type EmailImportSettingsSnapshot = {
emailImportEnabled: number;
emailImportAddress: string | null;
@@ -208,6 +220,7 @@ async function processEmailAttachment(
manusForgeApiUrl: settings.manusForgeApiUrl,
geminiApiKey: (settings as any).geminiApiKey || null,
} : undefined;
const qualityFieldsConfig = await getLlmFieldsConfigByUser(userId);
// Extract invoices
console.log(`[EmailImport] Starting invoice extraction (provider: ${aiSettings?.aiProvider || 'mistral'})...`);
@@ -237,6 +250,7 @@ async function processEmailAttachment(
for (let i = 0; i < result.invoices.length; i++) {
const invoiceData = result.invoices[i]!;
const invoiceWithQuality = withCalculatedInvoiceQuality(invoiceData, qualityFieldsConfig);
try {
// Update progress
@@ -244,19 +258,6 @@ async function processEmailAttachment(
processingProgress: `Extraction ${i + 1}/${result.invoiceCount} factures...`,
});
// Vérifier la blacklist (factures supprimées manuellement)
const blacklisted = await isInvoiceBlacklisted(invoiceData.invoiceNumber, userId);
if (blacklisted) {
duplicatesCount++;
duplicateDetails.push({
supplierName: invoiceData.supplierName,
invoiceNumber: invoiceData.invoiceNumber,
totalAmount: invoiceData.totalAmount,
reason: 'blacklisted',
});
continue;
}
// Check for duplicates (numéro de facture + montant)
const duplicate = await findDuplicateInvoice(
invoiceData.invoiceNumber,
@@ -299,7 +300,7 @@ async function processEmailAttachment(
totalAmount: invoiceData.totalAmount?.toString(),
recipientName: invoiceData.recipientName,
pageRange: invoiceData.pageRange,
qualityScore: invoiceData.qualityScore,
qualityScore: invoiceWithQuality.qualityScore,
extractedText: invoiceData.extractedText,
metadataFileKey: metadataKey,
metadataFileUrl: metadataUrl,
@@ -566,21 +567,63 @@ export async function testImapConnection(config: EmailImportConfig): Promise<{ s
}
/**
* Start email import service for a user
* Vérifie la boîte avant de créer la planification. Une authentification refusée
* ne doit jamais produire une boucle de tentatives automatiques sans fin.
*/
export async function startEmailImportService(userId: number): Promise<boolean> {
export async function startConfiguredEmailImportService(
config: EmailImportConfig,
frequencyMinutes: number,
connectionTester: EmailConnectionTester = testImapConnection,
emailCheckRunner: EmailCheckRunner = runEmailCheckExclusive,
): Promise<EmailImportServiceStartResult> {
const connection = await connectionTester(config);
if (!connection.success) {
console.error(
`[EmailImport] Service non démarré pour user ${config.userId}: ${connection.message}`,
);
return connection;
}
// Stop existing service if running only after the new configuration has passed validation.
stopEmailImportService(config.userId);
const frequencyMs = Math.max(1, frequencyMinutes || 30) * 60 * 1000;
console.log(
`[EmailImport] Starting email import service for user ${config.userId} with frequency ${frequencyMinutes || 30} minutes (auth: ${config.authMode || "basic"})`,
);
// Run immediately on start. The preflight above confirms that the account accepts the credentials.
emailCheckRunner(config).catch((error) => {
console.error(`[EmailImport] Error checking emails for user ${config.userId}:`, error);
});
const interval = setInterval(() => {
emailCheckRunner(config).catch((error) => {
console.error(`[EmailImport] Error checking emails for user ${config.userId}:`, error);
});
}, frequencyMs);
activeIntervals.set(config.userId, interval);
console.log(`[EmailImport] Email import service started for user ${config.userId}`);
return { success: true, message: "Service de réception e-mail démarré" };
}
/**
* Start email import service for a user.
*/
export async function startEmailImportService(userId: number): Promise<EmailImportServiceStartResult> {
try {
// Get user's import settings
const settings = await getImportSettingsByUser(userId);
if (!settings || settings.emailImportEnabled !== 1) {
console.log(`[EmailImport] Email import not enabled for user ${userId}`);
return false;
return { success: false, message: "Activez et enregistrez la réception automatique avant de démarrer le service." };
}
if (!settings.emailImportAddress || !settings.emailImportHost) {
console.log(`[EmailImport] Email import configuration incomplete for user ${userId}`);
return false;
return { success: false, message: "Configuration IMAP incomplète : adresse e-mail ou serveur IMAP manquant." };
}
const authMode = (settings as any).emailImportAuthMode as "basic" | "oauth2" || "basic";
@@ -588,16 +631,13 @@ export async function startEmailImportService(userId: number): Promise<boolean>
// Vérification selon le mode d'auth
if (authMode === "basic" && !settings.emailImportPassword) {
console.log(`[EmailImport] Email import password missing for user ${userId}`);
return false;
return { success: false, message: "Mot de passe IMAP manquant." };
}
if (authMode === "oauth2" && (!settings.azureTenantId || !settings.azureClientId || !settings.azureClientSecret)) {
console.log(`[EmailImport] OAuth2 credentials incomplete for user ${userId}`);
return false;
return { success: false, message: "Configuration OAuth2 IMAP incomplète : renseignez les paramètres Microsoft Azure AD." };
}
// Stop existing service if running
stopEmailImportService(userId);
const config: EmailImportConfig = {
userId,
emailAddress: settings.emailImportAddress,
@@ -611,31 +651,13 @@ export async function startEmailImportService(userId: number): Promise<boolean>
azureClientSecret: settings.azureClientSecret || undefined,
};
const frequencyMs = (settings.emailImportFrequency || 30) * 60 * 1000; // Convert minutes to milliseconds
console.log(
`[EmailImport] Starting email import service for user ${userId} with frequency ${settings.emailImportFrequency} minutes (auth: ${authMode})`
return startConfiguredEmailImportService(
config,
settings.emailImportFrequency || 30,
);
// Run immediately on start
runEmailCheckExclusive(config).catch((error) => {
console.error(`[EmailImport] Error checking emails for user ${userId}:`, error);
});
// Set up interval for periodic checks
const interval = setInterval(() => {
runEmailCheckExclusive(config).catch((error) => {
console.error(`[EmailImport] Error checking emails for user ${userId}:`, error);
});
}, frequencyMs);
activeIntervals.set(userId, interval);
console.log(`[EmailImport] Email import service started for user ${userId}`);
return true;
} catch (error) {
console.error(`[EmailImport] Error starting email import service for user ${userId}:`, error);
return false;
return { success: false, message: "Erreur technique lors du démarrage de la réception e-mail." };
}
}
@@ -658,6 +680,33 @@ export function isEmailImportServiceRunning(userId: number): boolean {
return activeIntervals.has(userId);
}
type EmailImportAccountControllerDependencies = {
start: (userId: number) => Promise<EmailImportServiceStartResult>;
stop: (userId: number) => void;
isRunning: (userId: number) => boolean;
};
/**
* Contrôles administratifs d'une boîte source. Les paramètres restent rattachés
* à leur compte propriétaire ; seul le service planifié est administré ici.
*/
export function createEmailImportAccountController(
dependencies: EmailImportAccountControllerDependencies = {
start: startEmailImportService,
stop: stopEmailImportService,
isRunning: isEmailImportServiceRunning,
},
) {
return {
startAccount: (userId: number) => dependencies.start(userId),
stopAccount: (userId: number): EmailImportServiceStartResult => {
dependencies.stop(userId);
return { success: true, message: "Service de réception e-mail arrêté" };
},
accountStatus: (userId: number) => ({ isRunning: dependencies.isRunning(userId) }),
};
}
async function triggerConfiguredEmailCheck(
userId: number,
requireAutomaticEnabled: boolean,

View File

@@ -7,6 +7,7 @@ import {
getSourceFileByContentHash,
updateSourceFile,
getUserSettings,
getLlmFieldsConfigByUser,
findDuplicateInvoice,
createInvoice,
createImportLog,
@@ -15,6 +16,7 @@ import { extractInvoicesWithMistral, generateMetadataJSON } from "./invoiceExtra
import { localStorageDelete, localStoragePut, generateStorageKey } from "./localStorage";
import { calculateFileSha256 } from "./fileFingerprint";
import { sendImportNotification } from "./notificationService";
import { withCalculatedInvoiceQuality } from "@shared/invoiceQuality";
interface FolderImportConfig {
userId: number;
@@ -99,6 +101,7 @@ async function processFolderFile(
} : undefined;
const model = settings?.llmModel || "mistral-large-latest";
const qualityFieldsConfig = await getLlmFieldsConfigByUser(userId);
// Extract invoices
console.log(`[FolderImport] Starting invoice extraction...`);
@@ -127,6 +130,7 @@ async function processFolderFile(
for (let i = 0; i < result.invoices.length; i++) {
const invoiceData = result.invoices[i]!;
const invoiceWithQuality = withCalculatedInvoiceQuality(invoiceData, qualityFieldsConfig);
try {
// Update progress
@@ -176,7 +180,7 @@ async function processFolderFile(
totalAmount: invoiceData.totalAmount?.toString(),
recipientName: invoiceData.recipientName,
pageRange: invoiceData.pageRange,
qualityScore: invoiceData.qualityScore,
qualityScore: invoiceWithQuality.qualityScore,
extractedText: invoiceData.extractedText,
metadataFileKey: metadataKey,
metadataFileUrl: metadataUrl,

View File

@@ -0,0 +1,22 @@
import { describe, expect, it } from "vitest";
import {
DEFAULT_IMPORT_LOG_PAGE_SIZE,
MAX_IMPORT_LOG_PAGE_SIZE,
normalizeImportLogPagination,
} from "../shared/importLogPagination";
describe("pagination des journaux d'import", () => {
it("retourne une page bornée par défaut au lieu de l'historique complet", () => {
expect(normalizeImportLogPagination()).toEqual({
limit: DEFAULT_IMPORT_LOG_PAGE_SIZE,
offset: 0,
});
});
it("borne les entrées utilisateur pour préserver la réactivité", () => {
expect(normalizeImportLogPagination({ limit: MAX_IMPORT_LOG_PAGE_SIZE + 1, offset: -20 })).toEqual({
limit: MAX_IMPORT_LOG_PAGE_SIZE,
offset: 0,
});
});
});

View File

@@ -0,0 +1,24 @@
import { describe, expect, it } from "vitest";
import {
IMPORT_LOG_RETENTION_OPTIONS,
getImportLogRetentionCutoff,
isImportLogRetentionMonths,
} from "@shared/importLogRetention";
describe("rétention des journaux d'import", () => {
it("propose uniquement les périodes de purge autorisées", () => {
expect(IMPORT_LOG_RETENTION_OPTIONS).toEqual([3, 6, 12]);
expect(isImportLogRetentionMonths(3)).toBe(true);
expect(isImportLogRetentionMonths(6)).toBe(true);
expect(isImportLogRetentionMonths(12)).toBe(true);
expect(isImportLogRetentionMonths(9)).toBe(false);
});
it("calcule une date limite calendaire sans modifier la date de référence", () => {
const now = new Date("2026-10-09T12:00:00.000Z");
const cutoff = getImportLogRetentionCutoff(6, now);
expect(cutoff.toISOString()).toBe("2026-04-09T12:00:00.000Z");
expect(now.toISOString()).toBe("2026-10-09T12:00:00.000Z");
});
});

View File

@@ -0,0 +1,23 @@
import { describe, expect, it } from "vitest";
import { parseImportResultSummary } from "@shared/importResultSummary";
describe("parseImportResultSummary", () => {
it("identifie un traitement terminé sans facture créée", () => {
expect(parseImportResultSummary("Terminé: 0 importée(s), 1 doublon(s)"))
.toEqual({ importedCount: 0, duplicateCount: 1, errorCount: 0 });
});
it("identifie une facture réellement créée", () => {
expect(parseImportResultSummary("Terminé: 1 importée(s), 0 doublon(s)"))
.toEqual({ importedCount: 1, duplicateCount: 0, errorCount: 0 });
});
it("conserve les erreurs éventuelles", () => {
expect(parseImportResultSummary("Terminé: 0 importée(s), 0 doublon(s), 1 erreur(s)"))
.toEqual({ importedCount: 0, duplicateCount: 0, errorCount: 1 });
});
it("retourne null pour un statut intermédiaire", () => {
expect(parseImportResultSummary("Extraction 1/1 factures...")).toBeNull();
});
});

View File

@@ -0,0 +1,30 @@
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import { describe, expect, it } from "vitest";
import { INVOICE_DELETE_ERROR, getSafeInvoiceDeletionError } from "./invoiceDeletionError";
const migrationPath = resolve(process.cwd(), "drizzle/0042_deleted_invoices_blacklist.sql");
const dbPath = resolve(process.cwd(), "server/db.ts");
describe("suppression de facture", () => {
it("ne renvoie jamais les détails SQL au navigateur", () => {
const error = new Error("Failed query: insert into `deletedInvoices` values (?, ?, ?)");
expect(getSafeInvoiceDeletionError(error)).toBe(INVOICE_DELETE_ERROR);
});
it("crée la blacklist de manière additive et idempotente", () => {
const migration = readFileSync(migrationPath, "utf8");
expect(migration).toContain("CREATE TABLE IF NOT EXISTS `deletedInvoices`");
expect(migration).toContain("CONSTRAINT `deletedInvoices_id` PRIMARY KEY(`id`)");
});
it("met à jour une entrée existante sans exiger de contrainte unique historique", () => {
const source = readFileSync(dbPath, "utf8");
expect(source).toContain("const blacklistEntry = await db.select");
expect(source).toContain("await db.update(deletedInvoices)");
expect(source).not.toContain("}).onDuplicateKeyUpdate({ set: { deletedAt: new Date() } });");
});
});

View File

@@ -0,0 +1,10 @@
/** Message utilisateur sans détails de requête ni données de facture. */
export const INVOICE_DELETE_ERROR = "La facture ne peut pas être supprimée pour le moment. Réessayez dans quelques instants.";
/**
* Les erreurs de persistance peuvent contenir une requête SQL et des paramètres.
* Ils restent réservés aux journaux serveur et ne doivent jamais atteindre le navigateur.
*/
export function getSafeInvoiceDeletionError(_error: unknown): string {
return INVOICE_DELETE_ERROR;
}

View File

@@ -0,0 +1,29 @@
import { describe, expect, it } from "vitest";
import { cleanJsonResponse } from "./invoiceExtractor";
describe("cleanJsonResponse", () => {
it("supprime les caractères de contrôle bruts dans une chaîne JSON LLM", () => {
const rawResponse = `{
"pageCount": 1,
"invoiceCount": 1,
"invoices": [{
"supplierName": "Manus AI",
"invoiceNumber": "J6LNGDAL-0021",
"extractedText": "Ligne valide\u0000suite du texte"
}]
}`;
const parsed = JSON.parse(cleanJsonResponse(rawResponse));
expect(parsed.invoices[0].invoiceNumber).toBe("J6LNGDAL-0021");
expect(parsed.invoices[0].extractedText).toBe("Ligne validesuite du texte");
});
it("préserve les retours à la ligne échappés dans les chaînes JSON", () => {
const rawResponse = '{"invoices":[{"extractedText":"Ligne 1\nLigne 2"}] }';
const parsed = JSON.parse(cleanJsonResponse(rawResponse));
expect(parsed.invoices[0].extractedText).toBe("Ligne 1\nLigne 2");
});
});

View File

@@ -75,9 +75,54 @@ async function extractTextFromPdf(pdfBuffer: Buffer): Promise<string> {
}
/**
* Clean JSON response from LLM by removing markdown code blocks
* Supprime ou échappe les caractères de contrôle bruts uniquement à
* l'intérieur des chaînes JSON. Certains moteurs retournent ponctuellement
* un caractère NUL provenant du texte OCR : JSON.parse le refuse alors que
* le reste de la réponse est exploitable.
*/
function cleanJsonResponse(content: string): string {
function normalizeControlCharactersInJsonStrings(content: string): string {
let normalized = "";
let inString = false;
let escaped = false;
for (const character of content) {
if (escaped) {
normalized += character;
escaped = false;
continue;
}
if (character === "\\") {
normalized += character;
escaped = true;
continue;
}
if (character === '"') {
inString = !inString;
normalized += character;
continue;
}
if (inString && character.charCodeAt(0) < 0x20) {
if (character === "\n") normalized += "\\n";
else if (character === "\r") normalized += "\\r";
else if (character === "\t") normalized += "\\t";
// Les autres contrôles (notamment NUL) n'ont pas de valeur métier.
continue;
}
normalized += character;
}
return normalized;
}
/**
* Clean JSON response from LLM by removing markdown code blocks.
* Exportée pour couvrir les réponses LLM mal formées en régression.
*/
export function cleanJsonResponse(content: string): string {
let cleaned = content.trim();
// Remove markdown code blocks
@@ -123,7 +168,76 @@ function cleanJsonResponse(content: string): string {
cleaned = cleaned.substring(startIdx, endIdx + 1);
}
return cleaned;
return normalizeControlCharactersInJsonStrings(cleaned);
}
export type InvoiceExtractionPromptInput = {
pageCount: number;
qualityScoreInstruction: string;
subscriptionInstruction: string;
keywordsHint: string;
};
/**
* Construit une demande JSON compacte. Le texte PDF reste en stockage local :
* les moteurs rapides à sortie limitée ne doivent pas le recopier dans leur JSON.
*/
export function buildInvoiceExtractionPrompt({
pageCount,
qualityScoreInstruction,
subscriptionInstruction,
keywordsHint,
}: InvoiceExtractionPromptInput): string {
return `Tu es un expert en extraction de données de factures. Analyse ce document PDF et extrais toutes les factures qu'il contient.
Pour chaque facture trouvée, extrais les informations suivantes:
- supplierName: Nom du fournisseur/vendeur (émetteur de la facture)
- invoiceNumber: Numéro de la facture
- invoiceDate: Date de la facture (format ISO 8601: YYYY-MM-DD)
- deliveryNoteNumber: Numéro du bon de livraison (si présent)
- orderNumber: Numéro de commande client (si présent)
- totalAmount: Montant total TTC (nombre décimal)
- recipientName: Nom du destinataire/client (société ou personne à qui la facture est adressée)
- pageRange: Plage de pages de cette facture (ex: "1-2" ou "5")
${qualityScoreInstruction}
${subscriptionInstruction}${keywordsHint}
Réponds UNIQUEMENT avec un objet JSON valide au format suivant:
{
"pageCount": ${pageCount},
"invoiceCount": <nombre de factures détectées>,
"invoices": [
{
"supplierName": "...",
"invoiceNumber": "...",
"invoiceDate": "YYYY-MM-DD",
"deliveryNoteNumber": "...",
"orderNumber": "...",
"totalAmount": 123.45,
"recipientName": "...",
"pageRange": "1-2",
"qualityScore": 85,
"isSubscription": false
}
]
}
Si une information n'est pas trouvée, utilise null. Ne retourne jamais le texte complet du PDF, ni aucun texte en dehors du JSON.`;
}
/** Attache le texte PDF déjà extrait localement, sans demander au modèle de le recopier. */
export function attachLocallyExtractedText(
result: Omit<MultiInvoiceResult, "invoices"> & { invoices: Array<Record<string, unknown>> },
pdfText: string,
): MultiInvoiceResult {
return {
...result,
invoices: result.invoices.map((invoice) => ({
...invoice,
invoiceDate: invoice.invoiceDate ? new Date(String(invoice.invoiceDate)) : null,
extractedText: pdfText,
})) as ExtractedInvoiceData[],
};
}
/**
@@ -200,44 +314,12 @@ export async function extractInvoicesWithMistral(
subscriptionInstruction = `- isSubscription: true UNIQUEMENT si la facture contient l'un de ces mots-clés exacts: ${customKeywords.subscription}, false sinon`;
}
// Prepare prompt for LLM
const prompt = `Tu es un expert en extraction de données de factures. Analyse ce document PDF et extrais toutes les factures qu'il contient.
Pour chaque facture trouvée, extrais les informations suivantes:
- supplierName: Nom du fournisseur/vendeur (émetteur de la facture)
- invoiceNumber: Numéro de la facture
- invoiceDate: Date de la facture (format ISO 8601: YYYY-MM-DD)
- deliveryNoteNumber: Numéro du bon de livraison (si présent)
- orderNumber: Numéro de commande client (si présent)
- totalAmount: Montant total TTC (nombre décimal)
- recipientName: Nom du destinataire/client (société ou personne à qui la facture est adressée)
- pageRange: Plage de pages de cette facture (ex: "1-2" ou "5")
${qualityScoreInstruction}
- extractedText: Texte complet extrait de la facture (tout le texte visible sur les pages de cette facture)
${subscriptionInstruction}${keywordsHint}
Réponds UNIQUEMENT avec un objet JSON valide au format suivant:
{
"pageCount": ${pageCount},
"invoiceCount": <nombre de factures détectées>,
"invoices": [
{
"supplierName": "...",
"invoiceNumber": "...",
"invoiceDate": "YYYY-MM-DD",
"deliveryNoteNumber": "...",
"orderNumber": "...",
"totalAmount": 123.45,
"recipientName": "...",
"pageRange": "1-2",
"qualityScore": 85,
"extractedText": "Texte complet de la facture...",
"isSubscription": false
}
]
}
Si une information n'est pas trouvée, utilise null. Ne retourne AUCUN texte en dehors du JSON.`;
const prompt = buildInvoiceExtractionPrompt({
pageCount,
qualityScoreInstruction,
subscriptionInstruction,
keywordsHint,
});
// Call Mistral LLM with extracted text
const fullPrompt = `${prompt}\n\nTexte extrait du PDF:\n${pdfText}`;
@@ -259,7 +341,11 @@ Si une information n'est pas trouvée, utilise null. Ne retourne AUCUN texte en
: JSON.stringify(response.choices[0]?.message?.content || "");
const processingTimeMs = Date.now() - startTime;
console.log("[Mistral] Raw response received:", rawResponse.substring(0, 200));
console.log("[Mistral] Raw response received", {
length: rawResponse.length,
startsWithJson: rawResponse.trimStart().startsWith("{"),
endsWithJson: rawResponse.trimEnd().endsWith("}"),
});
// Clean and parse response
const cleanedResponse = cleanJsonResponse(rawResponse);
@@ -269,7 +355,11 @@ Si une information n'est pas trouvée, utilise null. Ne retourne AUCUN texte en
result = JSON.parse(cleanedResponse);
} catch (parseError) {
console.error("[Mistral] Failed to parse JSON:", parseError);
console.error("[Mistral] Cleaned response:", cleanedResponse);
console.error("[Mistral] Invalid response shape", {
length: cleanedResponse.length,
startsWithJson: cleanedResponse.startsWith("{"),
endsWithJson: cleanedResponse.endsWith("}"),
});
// Log error to database
await createLlmLog({
@@ -307,11 +397,7 @@ Si une information n'est pas trouvée, utilise null. Ne retourne AUCUN texte en
processingTimeMs,
});
// Convert date strings to Date objects
result.invoices = result.invoices.map((inv) => ({
...inv,
invoiceDate: inv.invoiceDate ? new Date(inv.invoiceDate) : null,
}));
result = attachLocallyExtractedText(result as any, pdfText);
console.log(`[Mistral] Successfully extracted ${result.invoiceCount} invoice(s)`);

View File

@@ -0,0 +1,64 @@
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import { describe, expect, it } from "vitest";
import { calculateInvoiceQualityScore, withCalculatedInvoiceQuality } from "@shared/invoiceQuality";
const routerSource = readFileSync(resolve(process.cwd(), "server/routers.ts"), "utf8");
const emailImportSource = readFileSync(resolve(process.cwd(), "server/emailImportService.ts"), "utf8");
const folderImportSource = readFileSync(resolve(process.cwd(), "server/folderImportService.ts"), "utf8");
const webImportSource = readFileSync(resolve(process.cwd(), "server/_core/index.ts"), "utf8");
describe("score de qualité à l'import", () => {
it("calcule 100 % pour la facture lorsque tous les champs obligatoires configurés sont présents", () => {
const qualityScore = calculateInvoiceQualityScore({
supplierName: "Manus Butterfly",
invoiceNumber: "J6LNGDAL0021",
invoiceDate: new Date("2026-08-28"),
totalAmount: "120.00",
recipientName: "PAREIGE OLIVIER",
}, [
{ fieldName: "supplierName", isRequired: 1 },
{ fieldName: "invoiceNumber", isRequired: 1 },
{ fieldName: "invoiceDate", isRequired: 1 },
{ fieldName: "totalAmount", isRequired: 1 },
{ fieldName: "recipientName", isRequired: 1 },
{ fieldName: "orderNumber", isRequired: 0 },
]);
expect(qualityScore).toBe(100);
});
it("utilise le calcul serveur déterministe au lieu du score proposé par le modèle", () => {
expect(routerSource).toContain("withCalculatedInvoiceQuality(invoiceData, qualityFieldsConfig)");
expect(routerSource).toContain("qualityScore: invoiceWithQuality.qualityScore");
});
it("exécute le même recalcul pour chaque flux d’import", () => {
const requiredFields = [
{ fieldName: "supplierName", isRequired: 1 },
{ fieldName: "invoiceNumber", isRequired: 1 },
{ fieldName: "invoiceDate", isRequired: 1 },
{ fieldName: "totalAmount", isRequired: 1 },
{ fieldName: "recipientName", isRequired: 1 },
];
for (const rawModelScore of [0, 42, 90, 99]) {
const persistedInvoice = withCalculatedInvoiceQuality({
supplierName: "Manus Butterfly",
invoiceNumber: "J6LNGDAL0021",
invoiceDate: new Date("2026-08-28"),
totalAmount: "120.00",
recipientName: "PAREIGE OLIVIER",
qualityScore: rawModelScore,
}, requiredFields);
expect(persistedInvoice.qualityScore).toBe(100);
}
});
it("branche chaque flux sur le calcul partagé", () => {
for (const source of [routerSource, emailImportSource, folderImportSource, webImportSource]) {
expect(source).toContain("withCalculatedInvoiceQuality(");
expect(source).toContain("qualityFieldsConfig");
}
});
});

View File

@@ -0,0 +1,57 @@
import { describe, expect, it } from "vitest";
import { calculateInvoiceQualityScore } from "@shared/invoiceQuality";
const defaultRequiredFields = [
{ fieldName: "supplierName", isRequired: 1 },
{ fieldName: "invoiceNumber", isRequired: 1 },
{ fieldName: "invoiceDate", isRequired: 1 },
{ fieldName: "totalAmount", isRequired: 1 },
{ fieldName: "deliveryNoteNumber", isRequired: 0 },
];
describe("calculateInvoiceQualityScore", () => {
it("passe à 100 % lorsque tous les champs obligatoires sont complétés manuellement", () => {
expect(calculateInvoiceQualityScore({
supplierName: "Microsoft France",
invoiceNumber: "FAC-2026-001",
invoiceDate: new Date("2026-09-30"),
totalAmount: "0.00",
}, defaultRequiredFields)).toBe(100);
});
it("recalcule proportionnellement lorsque seuls certains champs obligatoires sont remplis", () => {
expect(calculateInvoiceQualityScore({
supplierName: "Microsoft France",
invoiceNumber: "FAC-2026-001",
invoiceDate: new Date("2026-09-30"),
totalAmount: " ",
}, defaultRequiredFields)).toBe(75);
});
it("utilise la configuration personnalisée des champs obligatoires", () => {
const configuration = [
{ fieldName: "recipientName", isRequired: 1 },
{ fieldName: "supplierName", isRequired: 0 },
];
expect(calculateInvoiceQualityScore({
recipientName: "ITINOVA",
supplierName: "",
}, configuration)).toBe(100);
});
it("utilise les champs obligatoires par défaut lorsqu’aucune configuration n’existe encore", () => {
expect(calculateInvoiceQualityScore({
supplierName: "Fournisseur",
invoiceNumber: "F-1",
invoiceDate: new Date("2026-09-30"),
totalAmount: "120.00",
}, [])).toBe(100);
});
it("retourne 100 % lorsque le compte a explicitement rendu tous ses champs optionnels", () => {
expect(calculateInvoiceQualityScore({}, [
{ fieldName: "supplierName", isRequired: 0 },
])).toBe(100);
});
});

View File

@@ -0,0 +1,28 @@
import { describe, expect, it } from "vitest";
import {
getManualWebImportAvailability,
runManualWebImport,
summarizeManualWebImport,
} from "./manualWebImport";
describe("manual web import", () => {
it("autorise uniquement le connecteur SFR actuellement implémenté", () => {
expect(getManualWebImportAvailability("sfr")).toEqual({ available: true });
expect(getManualWebImportAvailability("starlink")).toMatchObject({ available: false });
});
it("agrège les résultats d’envoi sans exposer de données de connexion", () => {
expect(
summarizeManualWebImport([
{ imported: 2, duplicates: 1, total: 3 },
{ imported: 0, duplicates: 1, total: 1 },
]),
).toEqual({ imported: 2, duplicates: 2, total: 4 });
});
it("refuse un type non pris en charge avant de démarrer le navigateur", async () => {
await expect(
runManualWebImport({ connectorType: "starlink" } as any),
).rejects.toThrow("n’est pas encore disponible");
});
});

129
server/manualWebImport.ts Normal file
View File

@@ -0,0 +1,129 @@
import { chromium } from "playwright-core";
import type { WebImportSource } from "../drizzle/schema";
export type ManualWebImportResult = {
imported: number;
duplicates: number;
total: number;
};
type InvoicePushResult = ManualWebImportResult;
type InvoicePusher = (fileName: string, pdfBuffer: Buffer) => Promise<InvoicePushResult>;
const SFR_INVOICE_LINK_SELECTOR =
'a[href*=".pdf"], a[href*="facture"], a[href*="invoice"], a[download]';
/** Types de connecteurs dont l’automate est réellement livré dans l’image applicative. */
export function getManualWebImportAvailability(connectorType: string):
| { available: true }
| { available: false; reason: string } {
if (connectorType === "sfr") return { available: true };
return {
available: false,
reason: `L’import manuel n’est pas encore disponible pour le connecteur « ${connectorType} ».`,
};
}
export function summarizeManualWebImport(results: InvoicePushResult[]): ManualWebImportResult {
return results.reduce(
(summary, result) => ({
imported: summary.imported + result.imported,
duplicates: summary.duplicates + result.duplicates,
total: summary.total + result.total,
}),
{ imported: 0, duplicates: 0, total: 0 },
);
}
function getApplicationBaseUrl(): string {
return process.env.APP_BASE_URL || `http://127.0.0.1:${process.env.PORT || 3000}`;
}
async function pushInvoiceToApplication(source: WebImportSource, fileName: string, pdfBuffer: Buffer) {
const response = await fetch(`${getApplicationBaseUrl()}/api/web-import/push-invoice`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
apiToken: source.apiToken,
fileName,
fileBase64: pdfBuffer.toString("base64"),
}),
});
if (!response.ok) {
throw new Error(`Le dépôt sécurisé du PDF a échoué (HTTP ${response.status}).`);
}
const result = (await response.json()) as Partial<InvoicePushResult>;
return {
imported: Number(result.imported || 0),
duplicates: Number(result.duplicates || 0),
total: Number(result.total || 0),
};
}
/**
* Télécharge les nouvelles factures SFR puis les soumet au même endpoint
* de contrôle, dédoublonnage, stockage et analyse IA que l’import automatique.
* Les identifiants restent confinés au processus serveur et ne sont jamais loggés.
*/
export async function runManualWebImport(
source: WebImportSource,
pushInvoice: InvoicePusher = (fileName, pdfBuffer) => pushInvoiceToApplication(source, fileName, pdfBuffer),
): Promise<ManualWebImportResult> {
const availability = getManualWebImportAvailability(source.connectorType);
if (!availability.available) throw new Error(availability.reason);
const browser = await chromium.launch({
headless: true,
executablePath: process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE || "/usr/bin/chromium",
args: ["--no-sandbox", "--disable-dev-shm-usage"],
});
try {
const context = await browser.newContext({ acceptDownloads: true });
const page = await context.newPage();
await page.goto(source.portalUrl, { waitUntil: "networkidle", timeout: 30_000 });
await page
.click('[id*="accept"], [class*="accept-cookie"], #didomi-notice-agree-button', { timeout: 3_000 })
.catch(() => undefined);
await page.fill('input[type="email"], input[name="login"], input[id*="login"], input[id*="email"]', source.loginEmail);
await page.fill('input[type="password"], input[name="password"], input[id*="password"]', source.loginPassword);
await page.click('button[type="submit"], input[type="submit"], button:has-text("Connexion"), button:has-text("Se connecter")');
await page.waitForLoadState("networkidle", { timeout: 15_000 }).catch(() => undefined);
const invoicePage = new URL("factures", source.portalUrl).toString();
await page.goto(invoicePage, { waitUntil: "networkidle", timeout: 20_000 }).catch(() => undefined);
const invoiceLinks = await page.$$eval(SFR_INVOICE_LINK_SELECTOR, (links) =>
links.map((link) => ({
href: (link as HTMLAnchorElement).href,
label: link.textContent?.trim() || "facture.pdf",
})),
);
const uniqueLinks = Array.from(
new Map(invoiceLinks.filter((link) => link.href).map((link) => [link.href, link])).values(),
);
const results: InvoicePushResult[] = [];
for (const link of uniqueLinks) {
// BrowserContext.request partage la session authentifiée du navigateur. Cela évite
// de dépendre du comportement du visualiseur PDF ou de l’événement download.
const response = await context.request.get(link.href, { timeout: 20_000 });
if (!response.ok()) {
throw new Error(`Téléchargement de facture indisponible (HTTP ${response.status()}).`);
}
const fileName = link.label || `sfr-facture-${Date.now()}.pdf`;
const pdfBuffer = Buffer.from(await response.body());
results.push(await pushInvoice(fileName, pdfBuffer));
}
return summarizeManualWebImport(results);
} finally {
await browser.close();
}
}

View File

@@ -0,0 +1,127 @@
import { describe, expect, it } from "vitest";
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import {
invokeReferentialIa,
testReferentialIaConnection,
type ReferentielIaConnection,
} from "./referentialIa";
import { readSecretFile, resolveServerSecret } from "./_core/env";
const connection: ReferentielIaConnection = {
baseUrl: "https://referentiel-ia-santinova.recette.santinova-soft.org",
token: "test-token",
};
function jsonResponse(body: unknown, status = 200) {
return new Response(JSON.stringify(body), {
status,
headers: { "content-type": "application/json" },
});
}
describe("Référentiel IA", () => {
it("lit un jeton serveur depuis un fichier monté sans le journaliser", () => {
const directory = mkdtempSync(join(tmpdir(), "referentiel-ia-"));
const secretFile = join(directory, "token");
try {
writeFileSync(secretFile, " server-only-token\n", { mode: 0o600 });
expect(readSecretFile(secretFile)).toBe("server-only-token");
expect(readSecretFile(join(directory, "absent"))).toBe("");
} finally {
rmSync(directory, { recursive: true, force: true });
}
});
it("utilise le secret monté lorsque la variable d'environnement est vide", () => {
const directory = mkdtempSync(join(tmpdir(), "referentiel-ia-"));
const secretFile = join(directory, "token");
try {
writeFileSync(secretFile, " mounted-secret\n", { mode: 0o600 });
expect(resolveServerSecret("", secretFile)).toBe("mounted-secret");
expect(resolveServerSecret(" ", secretFile)).toBe("mounted-secret");
expect(resolveServerSecret("environment-secret", secretFile)).toBe("environment-secret");
} finally {
rmSync(directory, { recursive: true, force: true });
}
});
it("transmet le slug du moteur décidé avant la requête OpenAI-compatible", async () => {
let receivedUrl = "";
let receivedInit: RequestInit | undefined;
let callCount = 0;
const result = await invokeReferentialIa(
{
messages: [{ role: "user", content: "Extrais la facture" }],
max_tokens: 1200,
},
connection,
async (url, init) => {
callCount += 1;
receivedUrl = String(url);
receivedInit = init;
if (callCount === 1) {
expect(receivedUrl).toBe("https://referentiel-ia-santinova.recette.santinova-soft.org/api/integration/v1/recommendations");
expect(JSON.parse(String(receivedInit?.body))).toEqual({
useCase: "texte",
optimizeCost: true,
needsFreeTier: false,
includeConnection: false,
});
return jsonResponse({
application: { name: "Démat. Facturation DSI" },
decision: { selected: { engine: { name: "SANTINO Expert", slug: "santino-expert" } } },
});
}
return jsonResponse({
id: "ref-1",
created: 1,
model: "selected-by-reference",
choices: [{ index: 0, message: { role: "assistant", content: "{}" }, finish_reason: "stop" }],
});
},
);
expect(receivedUrl).toBe("https://referentiel-ia-santinova.recette.santinova-soft.org/api/integration/v1/invoke");
expect(receivedInit?.headers).toMatchObject({ authorization: "Bearer test-token" });
expect(JSON.parse(String(receivedInit?.body))).toMatchObject({
engineSlug: "santino-expert",
useCase: "texte",
payload: {
messages: [{ role: "user", content: "Extrais la facture" }],
max_tokens: 1200,
stream: false,
},
});
expect(callCount).toBe(2);
expect(result.choices[0]?.message.content).toBe("{}");
});
it("retourne un diagnostic non sensible lorsqu'aucun jeton n'est configuré", async () => {
await expect(
invokeReferentialIa({ messages: [{ role: "user", content: "test" }] }, { baseUrl: "", token: "" }),
).rejects.toThrow("Référentiel IA");
});
it("confirme le moteur choisi sans déclencher d'analyse de facture", async () => {
const result = await testReferentialIaConnection(
connection,
async (_url, init) => {
expect(JSON.parse(String(init?.body))).toEqual({
useCase: "texte",
optimizeCost: true,
needsFreeTier: false,
includeConnection: false,
});
return jsonResponse({
application: { name: "Démat. Facturation DSI" },
decision: { selected: { engine: { name: "Manus Forge", slug: "manus-forge" }, connection: undefined } },
});
},
);
expect(result).toEqual({ applicationName: "Démat. Facturation DSI", engineName: "Manus Forge", model: null });
});
});

156
server/referentialIa.ts Normal file
View File

@@ -0,0 +1,156 @@
import type { InvokeParams, InvokeResult } from "./_core/llm";
import { ENV } from "./_core/env";
export type ReferentielIaConnection = {
baseUrl: string;
token: string;
};
type ReferentielIaRecommendation = {
application?: { name?: unknown };
decision?: {
selected?: {
engine?: { name?: unknown; slug?: unknown };
connection?: { model?: unknown };
} | null;
} | null;
};
type Fetcher = (input: string | URL, init?: RequestInit) => Promise<Response>;
const RECOMMENDATIONS_PATH = "/api/integration/v1/recommendations";
const INVOCATION_PATH = "/api/integration/v1/invoke";
/**
* Le jeton est exclusivement fourni au serveur via l'environnement. Il n'est
* jamais exposé à l'interface ni stocké dans les paramètres de l'utilisateur.
*/
export function getReferentielIaConnection(): ReferentielIaConnection | null {
const baseUrl = ENV.referentielIaUrl.trim().replace(/\/$/, "");
const token = ENV.referentielIaToken.trim();
if (!baseUrl || !token) return null;
try {
const url = new URL(baseUrl);
if (url.protocol !== "https:") return null;
return { baseUrl: url.toString().replace(/\/$/, ""), token };
} catch {
return null;
}
}
function requireConnection(connection?: ReferentielIaConnection | null): ReferentielIaConnection {
if (!connection?.baseUrl.trim() || !connection.token.trim()) {
throw new Error("Le Référentiel IA n'est pas configuré pour cette application.");
}
return connection;
}
async function readJson(response: Response): Promise<unknown> {
const raw = await response.text();
try {
return JSON.parse(raw) as unknown;
} catch {
return raw;
}
}
function toGatewayPayload(params: InvokeParams): Record<string, unknown> {
const payload: Record<string, unknown> = {
messages: params.messages,
max_tokens: params.maxTokens ?? params.max_tokens ?? 32768,
stream: false,
};
if (params.tools?.length) payload.tools = params.tools;
if (params.toolChoice ?? params.tool_choice) payload.tool_choice = params.toolChoice ?? params.tool_choice;
if (params.outputSchema ?? params.output_schema) payload.response_format = {
type: "json_schema",
json_schema: params.outputSchema ?? params.output_schema,
};
if (params.responseFormat ?? params.response_format) payload.response_format = params.responseFormat ?? params.response_format;
return payload;
}
function gatewayError(status: number): Error {
if (status === 401 || status === 403) return new Error("Le Référentiel IA refuse l'accès de cette application.");
if (status === 422) return new Error("Le moteur choisi dans le Référentiel IA n'est pas opérationnel.");
if (status === 429) return new Error("La limite d'usage définie dans le Référentiel IA est atteinte.");
return new Error(`Le Référentiel IA est indisponible (HTTP ${status}).`);
}
/** Demande au Référentiel le moteur actuellement décidé pour cette application. */
async function getReferentialIaRecommendation(
connection: ReferentielIaConnection,
fetcher: Fetcher,
): Promise<ReferentielIaRecommendation> {
const response = await fetcher(`${connection.baseUrl}${RECOMMENDATIONS_PATH}`, {
method: "POST",
headers: {
"content-type": "application/json",
authorization: `Bearer ${connection.token}`,
},
body: JSON.stringify({
useCase: "texte",
optimizeCost: true,
needsFreeTier: false,
includeConnection: false,
}),
signal: AbortSignal.timeout(15_000),
});
if (!response.ok) throw gatewayError(response.status);
return (await readJson(response)) as ReferentielIaRecommendation;
}
function selectedEngineSlug(recommendation: ReferentielIaRecommendation): string {
const slug = recommendation.decision?.selected?.engine?.slug;
if (typeof slug !== "string" || !slug.trim()) {
throw new Error("Le Référentiel IA ne fournit aucun moteur sélectionné pour cette application.");
}
return slug.trim();
}
/** Exécute l'appel sur le moteur décidé et tracé par le Référentiel IA. */
export async function invokeReferentialIa(
params: InvokeParams,
connection = getReferentielIaConnection(),
fetcher: Fetcher = fetch,
): Promise<InvokeResult> {
const config = requireConnection(connection);
const recommendation = await getReferentialIaRecommendation(config, fetcher);
const engineSlug = selectedEngineSlug(recommendation);
const response = await fetcher(`${config.baseUrl}${INVOCATION_PATH}`, {
method: "POST",
headers: {
"content-type": "application/json",
authorization: `Bearer ${config.token}`,
},
body: JSON.stringify({
engineSlug,
useCase: "texte",
payload: toGatewayPayload(params),
}),
signal: AbortSignal.timeout(120_000),
});
if (!response.ok) throw gatewayError(response.status);
return (await readJson(response)) as InvokeResult;
}
export type ReferentielIaConnectionTest = {
applicationName: string | null;
engineName: string | null;
model: string | null;
};
/** Vérifie l'autorisation et le moteur choisi, sans envoyer de facture au modèle. */
export async function testReferentialIaConnection(
connection = getReferentielIaConnection(),
fetcher: Fetcher = fetch,
): Promise<ReferentielIaConnectionTest> {
const config = requireConnection(connection);
const data = await getReferentialIaRecommendation(config, fetcher);
return {
applicationName: typeof data.application?.name === "string" ? data.application.name : null,
engineName: typeof data.decision?.selected?.engine?.name === "string" ? data.decision.selected.engine.name : null,
model: typeof data.decision?.selected?.connection?.model === "string" ? data.decision.selected.connection.model : null,
};
}

View File

@@ -1,6 +1,11 @@
import { z } from "zod";
import { COOKIE_NAME } from "@shared/const";
import { BAP_MIN_QUALITY_SCORE, meetsBapQualityThreshold } from "@shared/bapEligibility";
import { requiresBapBeforeOriginalPdfExport } from "@shared/bapExportGuard";
import { calculateInvoiceQualityScore, withCalculatedInvoiceQuality } from "@shared/invoiceQuality";
import { normalizeImportLogPagination } from "@shared/importLogPagination";
import { getImportLogRetentionCutoff, isImportLogRetentionMonths } from "@shared/importLogRetention";
import { webPortalUrlSchema } from "./webPortalUrl";
interface Condition {
field: string;
@@ -28,6 +33,7 @@ import {
createSourceFile,
getSourceFileByContentHash,
getSourceFileById,
countInvoicesBySourceFileId,
updateSourceFile,
getUserSettings,
upsertUserSettings,
@@ -37,7 +43,6 @@ import {
toggleUserActive,
deleteUser,
findDuplicateInvoice,
isInvoiceBlacklisted,
getAllInvoices,
getAllImportLogs,
getAllImportLogsWithDetails,
@@ -46,6 +51,8 @@ import {
getImportLogsByUser,
getImportLogsWithDetailsByUser,
deleteAllImportLogs,
countImportLogsBefore,
deleteImportLogsBefore,
getLlmLogsBySourceFile,
getLlmLogsByInvoice,
getImportSettingsByUser,
@@ -92,6 +99,7 @@ import {
getWebImportSourceById,
createWebImportSource,
updateWebImportSource,
updateWebImportSourceStatus,
deleteWebImportSource,
} from "./db";
import { loginLocal, hashPassword, getAzureAuthUrl, isAzureAdConfigured } from "./auth";
@@ -99,12 +107,17 @@ import fsSync from "fs";
import pathSync from "path";
import { extractInvoicesWithMistral, generateMetadataJSON } from "./invoiceExtractor";
import { calculateFileSha256 } from "./fileFingerprint";
import { localStoragePut, generateStorageKey } from "./localStorage";
import { localStoragePut, localStorageExists, generateStorageKey } from "./localStorage";
import { shouldReprocessEmptySource } from "@shared/sourceFileReimport";
import { testReferentialIaConnection } from "./referentialIa";
import { testSftpConnection, exportInvoiceToSftp, getUserSftpConfig } from "./sftpExport";
import { drawBapCartouche } from "./bapCartouche";
import { resolveBapExportDestination } from "./exportDestinationResolver";
import { startEmailImportService, stopEmailImportService, isEmailImportServiceRunning, triggerEmailCheck, triggerManualEmailCheck, testImapConnection } from "./emailImportService";
import { createEmailImportAccountController, startEmailImportService, stopEmailImportService, isEmailImportServiceRunning, triggerEmailCheck, triggerManualEmailCheck, testImapConnection } from "./emailImportService";
import { startFolderImportService, stopFolderImportService, isFolderImportServiceRunning } from "./folderImportService";
import { getSafeWebImportSourceError, logWebImportSourceSaveFailure } from "./webImportSourcesError";
import { getSafeInvoiceDeletionError } from "./invoiceDeletionError";
import { getManualWebImportAvailability, runManualWebImport } from "./manualWebImport";
import { TRPCError } from "@trpc/server";
import { processFreeproExcel } from "./freeproService";
import { runFreeproAutoImport, testFreeproConnection, startFreeproAutoJob, stopFreeproAutoJob, frequencyToMs } from "./freeproAutoImport";
@@ -199,37 +212,62 @@ export const appRouter = router({
// Il reste fiable même si le nom du fichier ou le compte utilisateur diffère.
const contentHash = calculateFileSha256(fileBuffer);
const existingSource = await getSourceFileByContentHash(contentHash);
let sourceFile;
let sourceFileKey: string;
let sourceFileUrl: string;
let reprocessed = false;
if (existingSource) {
throw new TRPCError({
code: "CONFLICT",
message: `Ce PDF a déjà été importé (${existingSource.fileName}).`,
const invoiceCount = await countInvoicesBySourceFileId(existingSource.id);
if (!shouldReprocessEmptySource({
sourceUserId: existingSource.userId,
requestingUserId: userId,
invoiceCount,
})) {
throw new TRPCError({
code: "CONFLICT",
message: `Ce PDF a déjà été importé (${existingSource.fileName}).`,
});
}
// Une purge peut supprimer les factures tout en conservant la source et son empreinte.
// On relance alors l'analyse depuis la source existante plutôt que de contourner l'anti-doublon.
sourceFile = existingSource;
sourceFileKey = existingSource.fileKey;
sourceFileUrl = existingSource.fileUrl;
reprocessed = true;
if (!await localStorageExists(sourceFileKey)) {
await localStoragePut(sourceFileKey, fileBuffer, "application/pdf");
}
await updateSourceFile(sourceFile.id, {
processingStatus: "processing",
totalInvoicesDetected: 0,
processingProgress: "Nouvelle analyse après purge...",
});
} else {
// Store source file
sourceFileKey = generateStorageKey(userId, input.fileName);
console.log(`[Upload] Generated storage key: ${sourceFileKey}`);
try {
const result = await localStoragePut(sourceFileKey, fileBuffer, "application/pdf");
sourceFileUrl = result.url;
console.log(`[Upload] File stored successfully at: ${sourceFileUrl}`);
} catch (error) {
console.error(`[Upload] FAILED to store file:`, error);
throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "Failed to store PDF file" });
}
sourceFile = await createSourceFile({
userId,
fileName: input.fileName,
fileKey: sourceFileKey,
fileUrl: sourceFileUrl,
contentHash,
processingStatus: "processing",
});
}
// Store source file
const sourceFileKey = generateStorageKey(userId, input.fileName);
console.log(`[Upload] Generated storage key: ${sourceFileKey}`);
let sourceFileUrl: string;
try {
const result = await localStoragePut(sourceFileKey, fileBuffer, "application/pdf");
sourceFileUrl = result.url;
console.log(`[Upload] File stored successfully at: ${sourceFileUrl}`);
} catch (error) {
console.error(`[Upload] FAILED to store file:`, error);
throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "Failed to store PDF file" });
}
// Create source file record
const sourceFile = await createSourceFile({
userId,
fileName: input.fileName,
fileKey: sourceFileKey,
fileUrl: sourceFileUrl,
contentHash,
processingStatus: "processing",
});
// Start extraction process (async - don't wait)
(async () => {
try {
@@ -253,6 +291,7 @@ export const appRouter = router({
manusForgeApiUrl: (settings as any).manusForgeApiUrl || null,
geminiApiKey: (settings as any).geminiApiKey || null,
} : undefined;
const qualityFieldsConfig = await getLlmFieldsConfigByUser(userId);
// Extract invoices
const result = await extractInvoicesWithMistral(
@@ -279,6 +318,9 @@ export const appRouter = router({
for (let i = 0; i < result.invoices.length; i++) {
const invoiceData = result.invoices[i]!;
// Le score affiché est déterministe : les champs obligatoires
// configurés prévalent sur l'estimation fournie par le modèle.
const invoiceWithQuality = withCalculatedInvoiceQuality(invoiceData, qualityFieldsConfig);
try {
// Update progress
@@ -286,19 +328,6 @@ export const appRouter = router({
processingProgress: `Extraction ${i + 1}/${result.invoiceCount} factures...`,
});
// Vérifier la blacklist (factures supprimées manuellement)
const blacklisted = await isInvoiceBlacklisted(invoiceData.invoiceNumber, userId);
if (blacklisted) {
duplicatesCount++;
duplicateDetails.push({
supplierName: invoiceData.supplierName,
invoiceNumber: invoiceData.invoiceNumber,
totalAmount: invoiceData.totalAmount,
reason: 'blacklisted',
});
continue;
}
// Check for duplicates (numéro de facture + montant)
const duplicate = await findDuplicateInvoice(
invoiceData.invoiceNumber,
@@ -341,7 +370,7 @@ export const appRouter = router({
totalAmount: invoiceData.totalAmount?.toString(),
recipientName: invoiceData.recipientName,
pageRange: invoiceData.pageRange,
qualityScore: invoiceData.qualityScore,
qualityScore: invoiceWithQuality.qualityScore,
extractedText: invoiceData.extractedText,
isSubscription: invoiceData.isSubscription ? 1 : 0,
metadataFileKey: metadataKey,
@@ -432,7 +461,7 @@ export const appRouter = router({
}
})();
return { sourceFileId: sourceFile.id };
return { sourceFileId: sourceFile.id, reprocessed };
}),
list: protectedProcedure.query(async ({ ctx }) => {
@@ -477,12 +506,22 @@ export const appRouter = router({
throw new TRPCError({ code: "NOT_FOUND" });
}
// Le score reflète toujours les valeurs actuellement enregistrées, y compris
// celles corrigées manuellement. La configuration est celle du propriétaire
// de la facture afin de conserver la même règle que lors de son extraction.
const fieldsConfiguration = await getLlmFieldsConfigByUser(invoice.userId);
const qualityScore = calculateInvoiceQualityScore(
{ ...invoice, ...input.data },
fieldsConfiguration,
);
await updateInvoice(input.id, {
...input.data,
qualityScore,
manuallyEdited: 1,
});
return { success: true };
return { success: true, qualityScore };
}),
delete: protectedProcedure
@@ -493,7 +532,18 @@ export const appRouter = router({
throw new TRPCError({ code: "NOT_FOUND" });
}
await deleteInvoice(input.id);
try {
await deleteInvoice(input.id);
} catch (error) {
console.error("[InvoiceDelete] Archive or deletion failed", {
invoiceId: input.id,
error: error instanceof Error ? error.message : String(error),
});
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: getSafeInvoiceDeletionError(error),
});
}
return { success: true };
}),
@@ -1222,7 +1272,7 @@ export const appRouter = router({
sftpAutoExport: z.number().optional(),
llmLogsRetentionMonths: z.number().optional(),
learningConfidenceThreshold: z.number().min(1).optional(),
aiProvider: z.enum(["mistral", "manus", "gemini"]).optional(),
aiProvider: z.enum(["mistral", "manus", "gemini", "referentiel"]).optional(),
mistralApiKey: z.string().optional(),
manusForgeApiKey: z.string().optional(),
manusForgeApiUrl: z.string().optional(),
@@ -1235,6 +1285,12 @@ export const appRouter = router({
});
return { success: true };
}),
/** Vérifie le choix centralisé sans transmettre de facture ni de clé au navigateur. */
testReferentielIa: protectedProcedure.mutation(async () => {
const result = await testReferentialIaConnection();
return { success: true, ...result };
}),
}),
// ============= ADMIN ROUTES =============
@@ -1357,6 +1413,19 @@ export const appRouter = router({
});
}
// Un PDF hors abonnement doit suivre le circuit BAP. Sans ce contrôle
// serveur, l'export manuel contournait la validation BAP puis rendait
// l'état métier incohérent dans la liste des factures.
const invoicesAwaitingBap = invoices.filter(
(invoice) => invoice && requiresBapBeforeOriginalPdfExport(invoice),
);
if (invoicesAwaitingBap.length > 0) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "La validation BAP est requise avant l'export PDF des factures hors abonnement.",
});
}
// La destination est résolue facture par facture : une sélection peut
// contenir plusieurs abonnements avec des règles de priorité distinctes.
const copiedFiles: string[] = [];
@@ -1598,6 +1667,19 @@ export const appRouter = router({
throw new TRPCError({ code: "BAD_REQUEST", message: "SFTP not configured" });
}
const selectedInvoices = await Promise.all(input.invoiceIds.map(id => getInvoiceById(id)));
const invoicesAwaitingBap = selectedInvoices.filter(
(invoice) => invoice
&& (ctx.user.role === "admin" || invoice.userId === ctx.user.id)
&& requiresBapBeforeOriginalPdfExport(invoice),
);
if (invoicesAwaitingBap.length > 0) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "La validation BAP est requise avant l'export SFTP des factures hors abonnement.",
});
}
// Load user settings to check recipient filter
const userSettings = await getUserSettings(ctx.user.id);
const recipientFilter = (userSettings as any)?.sftpRecipientFilter?.trim() || "";
@@ -1649,12 +1731,15 @@ export const appRouter = router({
// ============= IMPORT LOGS ROUTES =============
importLogs: router({
getByUser: protectedProcedure.query(async ({ ctx }) => {
getByUser: protectedProcedure
.input(z.object({ limit: z.number().int().positive().optional(), offset: z.number().int().min(0).optional() }).optional())
.query(async ({ ctx, input }) => {
const pagination = normalizeImportLogPagination(input);
// Les admins voient tous les logs d'import
if (ctx.user.role === 'admin') {
return getAllImportLogsWithDetails();
return getAllImportLogsWithDetails(pagination);
}
return getImportLogsWithDetailsByUser(ctx.user.id);
return getImportLogsWithDetailsByUser(ctx.user.id, pagination);
}),
deleteAll: protectedProcedure.mutation(async ({ ctx }) => {
@@ -1666,6 +1751,34 @@ export const appRouter = router({
}
return { success: true };
}),
retentionPreview: protectedProcedure
.input(z.object({ months: z.number().int() }))
.query(async ({ ctx, input }) => {
if (ctx.user.role !== "admin") {
throw new TRPCError({ code: "FORBIDDEN", message: "Réservé aux administrateurs." });
}
if (!isImportLogRetentionMonths(input.months)) {
throw new TRPCError({ code: "BAD_REQUEST", message: "Durée de rétention invalide." });
}
const cutoff = getImportLogRetentionCutoff(input.months);
const count = await countImportLogsBefore(cutoff);
return { months: input.months, cutoff, count };
}),
purgeBeforeRetention: protectedProcedure
.input(z.object({ months: z.number().int() }))
.mutation(async ({ ctx, input }) => {
if (ctx.user.role !== "admin") {
throw new TRPCError({ code: "FORBIDDEN", message: "Réservé aux administrateurs." });
}
if (!isImportLogRetentionMonths(input.months)) {
throw new TRPCError({ code: "BAD_REQUEST", message: "Durée de rétention invalide." });
}
const cutoff = getImportLogRetentionCutoff(input.months);
const deletedCount = await deleteImportLogsBefore(cutoff);
return { months: input.months, cutoff, deletedCount };
}),
}),
// ============= LLM LOGS ROUTES =============
@@ -1704,8 +1817,7 @@ export const appRouter = router({
// ============= EMAIL IMPORT SERVICE ROUTES =============
emailImportService: router({
start: protectedProcedure.mutation(async ({ ctx }) => {
const started = await startEmailImportService(ctx.user.id);
return { success: started };
return startEmailImportService(ctx.user.id);
}),
stop: protectedProcedure.mutation(async ({ ctx }) => {
@@ -1727,6 +1839,27 @@ export const appRouter = router({
return getEmailImportAccounts();
}),
/** Démarre la réception automatique pour la boîte source sélectionnée par un administrateur. */
startAccount: adminProcedure
.input(z.object({ userId: z.number().int().positive() }))
.mutation(async ({ input }) => {
return createEmailImportAccountController().startAccount(input.userId);
}),
/** Arrête la réception automatique pour la boîte source sélectionnée par un administrateur. */
stopAccount: adminProcedure
.input(z.object({ userId: z.number().int().positive() }))
.mutation(async ({ input }) => {
return createEmailImportAccountController().stopAccount(input.userId);
}),
/** Retourne le statut en mémoire d’une boîte source administrée. */
accountStatus: adminProcedure
.input(z.object({ userId: z.number().int().positive() }))
.query(async ({ input }) => {
return createEmailImportAccountController().accountStatus(input.userId);
}),
/** Déclenche une seule lecture IMAP sans activer ni planifier le service automatique. */
checkAccountNow: adminProcedure
.input(z.object({ userId: z.number().int().positive() }))
@@ -2712,14 +2845,21 @@ export const appRouter = router({
.input(z.object({
name: z.string().min(1).max(100),
connectorType: z.string().min(1).max(50),
portalUrl: z.string().url(),
portalUrl: webPortalUrlSchema,
loginEmail: z.string().min(1),
loginPassword: z.string().min(1),
frequency: z.enum(['manual', 'daily', 'weekly', 'monthly']).default('monthly'),
autoEnabled: z.number().min(0).max(1).default(0),
}))
.mutation(async ({ input, ctx }) => {
return createWebImportSource({ ...input, userId: ctx.user.id });
try {
return await createWebImportSource({ ...input, userId: ctx.user.id });
} catch (error) {
// Les erreurs MySQL/Drizzle peuvent contenir la requête et les paramètres saisis.
// Elles restent masquées côté navigateur.
logWebImportSourceSaveFailure(error);
throw getSafeWebImportSourceError(error);
}
}),
update: protectedProcedure
@@ -2727,7 +2867,7 @@ export const appRouter = router({
id: z.number(),
name: z.string().min(1).max(100).optional(),
connectorType: z.string().min(1).max(50).optional(),
portalUrl: z.string().url().optional(),
portalUrl: webPortalUrlSchema.optional(),
loginEmail: z.string().min(1).optional(),
loginPassword: z.string().optional(),
frequency: z.enum(['manual', 'daily', 'weekly', 'monthly']).optional(),
@@ -2763,6 +2903,38 @@ export const appRouter = router({
}
return { apiToken: source.apiToken };
}),
/** Lance ponctuellement un connecteur pris en charge, sans activer de planification. */
runManual: protectedProcedure
.input(z.object({ id: z.number() }))
.mutation(async ({ input, ctx }) => {
const source = await getWebImportSourceById(input.id);
if (!source || source.userId !== ctx.user.id) {
throw new TRPCError({ code: "NOT_FOUND", message: "Source introuvable" });
}
const availability = getManualWebImportAvailability(source.connectorType);
if (!availability.available) {
throw new TRPCError({ code: "BAD_REQUEST", message: availability.reason });
}
try {
const result = await runManualWebImport(source);
return { success: true, ...result };
} catch (error) {
// Ne jamais inclure une erreur de portail dans l’interface : elle peut contenir
// des informations de session ou des sélecteurs internes.
const technicalCode = error && typeof error === "object" && "code" in error && typeof error.code === "string"
? error.code
: "unknown";
console.error("[WebImport] Échec d’import manuel", { sourceId: source.id, technicalCode });
await updateWebImportSourceStatus(source.id, "Erreur lors de l’import manuel", 0, false);
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "L’import manuel n’a pas abouti. Consultez le statut du connecteur puis réessayez.",
});
}
}),
}),
// ============= BACKUP ROUTES =============

View File

@@ -0,0 +1,31 @@
import { describe, expect, it } from "vitest";
import {
attachLocallyExtractedText,
buildInvoiceExtractionPrompt,
} from "./invoiceExtractor";
describe("contrat compact pour les moteurs à sortie limitée", () => {
it("ne demande pas au modèle de recopier le texte complet du PDF", () => {
const prompt = buildInvoiceExtractionPrompt({
pageCount: 1,
qualityScoreInstruction: "- qualityScore: score",
subscriptionInstruction: "- isSubscription: false",
keywordsHint: "",
});
expect(prompt).not.toContain("- extractedText:");
expect(prompt).toContain("Ne retourne jamais le texte complet du PDF");
});
it("conserve le texte extrait localement après le JSON métier", () => {
const text = "texte extrait localement";
const result = attachLocallyExtractedText({
pageCount: 1,
invoiceCount: 1,
invoices: [{ invoiceDate: "2026-08-28", supplierName: "Fournisseur" }],
} as any, text);
expect(result.invoices[0].extractedText).toBe(text);
expect(result.invoices[0].invoiceDate).toEqual(new Date("2026-08-28"));
});
});

View File

@@ -0,0 +1,22 @@
import { describe, expect, it } from "vitest";
import { shouldReprocessEmptySource } from "@shared/sourceFileReimport";
describe("shouldReprocessEmptySource", () => {
it("réutilise une source du même utilisateur lorsqu'une purge a supprimé toutes ses factures", () => {
expect(
shouldReprocessEmptySource({ sourceUserId: 2, requestingUserId: 2, invoiceCount: 0 }),
).toBe(true);
});
it("conserve la protection contre les doublons quand la source contient déjà une facture", () => {
expect(
shouldReprocessEmptySource({ sourceUserId: 2, requestingUserId: 2, invoiceCount: 1 }),
).toBe(false);
});
it("ne réutilise jamais une source appartenant à un autre utilisateur", () => {
expect(
shouldReprocessEmptySource({ sourceUserId: 3, requestingUserId: 2, invoiceCount: 0 }),
).toBe(false);
});
});

View File

@@ -0,0 +1,29 @@
import { describe, expect, it } from "vitest";
import {
WEB_IMPORT_SOURCE_SAVE_ERROR,
getSafeWebImportSourceError,
getWebImportSourceTechnicalCode,
} from "./webImportSourcesError";
describe("getSafeWebImportSourceError", () => {
it("never returns the SQL statement or connector parameters to the client", () => {
const databaseError = new Error(
"Failed query: insert into `webImportSources` (...) values (...) params: amazon@example.fr, secret-value",
);
const safeError = getSafeWebImportSourceError(databaseError);
expect(safeError.code).toBe("INTERNAL_SERVER_ERROR");
expect(safeError.message).toBe(WEB_IMPORT_SOURCE_SAVE_ERROR);
expect(safeError.message).not.toContain("insert into");
expect(safeError.message).not.toContain("secret-value");
});
it("retient uniquement le code du pilote, même lorsque celui-ci est imbriqué", () => {
const databaseError = Object.assign(new Error("params: user@example.fr, secret-value"), {
cause: { code: "ER_NO_SUCH_TABLE", message: "contains secret-value" },
});
expect(getWebImportSourceTechnicalCode(databaseError)).toBe("ER_NO_SUCH_TABLE");
});
});

View File

@@ -0,0 +1,37 @@
import { TRPCError } from "@trpc/server";
export const WEB_IMPORT_SOURCE_SAVE_ERROR =
"Impossible d’enregistrer le connecteur. Vérifiez les informations saisies puis réessayez.";
/**
* Masque toute erreur Drizzle/MySQL avant son retour au navigateur : les erreurs
* de pilote peuvent contenir la requête SQL et les paramètres de connexion.
*/
export function getSafeWebImportSourceError(_error: unknown): TRPCError {
return new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: WEB_IMPORT_SOURCE_SAVE_ERROR,
});
}
/** Extrait uniquement le code du pilote, sans conserver son message ni ses paramètres. */
export function getWebImportSourceTechnicalCode(error: unknown): string {
if (!error || typeof error !== "object") return "unknown";
const directCode = "code" in error && typeof error.code === "string" ? error.code : null;
if (directCode) return directCode;
const cause = "cause" in error ? error.cause : null;
if (cause && typeof cause === "object" && "code" in cause && typeof cause.code === "string") {
return cause.code;
}
return "unknown";
}
/** Journalise uniquement les métadonnées techniques non sensibles. */
export function logWebImportSourceSaveFailure(error: unknown): void {
const technicalCode = getWebImportSourceTechnicalCode(error);
console.error("[WebImportSources] Échec d’enregistrement", { technicalCode });
}

View File

@@ -0,0 +1,22 @@
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import { describe, expect, it } from "vitest";
const migration = readFileSync(
resolve(process.cwd(), "drizzle/0036_broken_rattler.sql"),
"utf8",
);
describe("webImportSources migration", () => {
it("conserve l'instruction historique enregistrée", () => {
expect(migration).toMatch(/CREATE TABLE `webImportSources`/);
expect(migration).not.toMatch(/IF NOT EXISTS/);
});
it("définit les colonnes nécessaires au connecteur sans modifier les factures", () => {
expect(migration).toContain("`portalUrl` varchar(500) NOT NULL");
expect(migration).toContain("`loginPassword` text NOT NULL");
expect(migration).toContain("`apiToken` varchar(128) NOT NULL");
expect(migration).not.toContain("invoices");
});
});

View File

@@ -0,0 +1,49 @@
import { describe, expect, it } from "vitest";
import { isValidWebPortalUrl, normalizeWebPortalUrl } from "@shared/webPortalUrl";
import { webPortalUrlSchema } from "./webPortalUrl";
describe("normalizeWebPortalUrl", () => {
it("ajoute HTTPS aux adresses de portail saisies sans protocole", () => {
expect(normalizeWebPortalUrl("www.sfr.fr/mon-espace-client")).toBe(
"https://www.sfr.fr/mon-espace-client",
);
});
it("retire les espaces sans modifier une URL HTTPS déjà valide", () => {
expect(normalizeWebPortalUrl(" https://pro.free.fr/ ")).toBe("https://pro.free.fr/");
});
it("ne transforme pas un protocole non web en URL HTTPS", () => {
expect(normalizeWebPortalUrl("ftp://example.org/factures")).toBe("ftp://example.org/factures");
});
});
describe("isValidWebPortalUrl", () => {
it("accepte exclusivement les URLs HTTP ou HTTPS avec un hôte", () => {
expect(isValidWebPortalUrl("https://espaceclient.orange.fr/")).toBe(true);
expect(isValidWebPortalUrl("http://intranet.local/factures")).toBe(true);
});
it("refuse les valeurs vides, incomplètes ou avec un protocole non web", () => {
expect(isValidWebPortalUrl("")).toBe(false);
expect(isValidWebPortalUrl("https://")).toBe(false);
expect(isValidWebPortalUrl("ftp://example.org/factures")).toBe(false);
});
});
describe("webPortalUrlSchema", () => {
it("normalise l’URL reçue par l’API avant de la persister", () => {
expect(webPortalUrlSchema.parse("espaceclient.orange.fr")).toBe(
"https://espaceclient.orange.fr",
);
});
it("retourne un message métier pour une URL de portail non HTTP(S)", () => {
const result = webPortalUrlSchema.safeParse("ftp://example.org/factures");
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.issues[0]?.message).toContain("URL HTTP(S) valide");
}
});
});

13
server/webPortalUrl.ts Normal file
View File

@@ -0,0 +1,13 @@
import { z } from "zod";
import { isValidWebPortalUrl, normalizeWebPortalUrl } from "@shared/webPortalUrl";
/**
* Normalise les domaines saisis sans protocole puis impose une URL HTTP(S).
* Le même schéma est utilisé à la création et à la mise à jour d’un connecteur.
*/
export const webPortalUrlSchema = z
.string()
.transform(normalizeWebPortalUrl)
.refine(isValidWebPortalUrl, {
message: "Saisissez une URL HTTP(S) valide, par exemple https://espaceclient.exemple.fr.",
});

26
shared/bapExportGuard.ts Normal file
View File

@@ -0,0 +1,26 @@
/**
* Une facture hors abonnement doit être validée BAP avant l'export de son PDF
* d'origine. Les abonnements ne suivent pas le circuit BAP.
*/
export function requiresBapBeforeOriginalPdfExport(input: {
isSubscription?: number | boolean | null;
bapValidated?: number | boolean | null;
}): boolean {
const isSubscription = input.isSubscription === 1 || input.isSubscription === true;
const isBapValidated = input.bapValidated === 1 || input.bapValidated === true;
return !isSubscription && !isBapValidated;
}
/**
* Signale un export historique réalisé avant la validation BAP. Ce cas est
* conservé pour l'audit, mais l'interface doit rendre la régularisation BAP
* possible et explicite.
*/
export function wasExportedBeforeBap(input: {
exportStatus?: string | null;
isSubscription?: number | boolean | null;
bapValidated?: number | boolean | null;
}): boolean {
return input.exportStatus === "exported" && requiresBapBeforeOriginalPdfExport(input);
}

View File

@@ -0,0 +1,22 @@
export const DEFAULT_IMPORT_LOG_PAGE_SIZE = 200;
export const MAX_IMPORT_LOG_PAGE_SIZE = 500;
export type ImportLogPaginationInput = {
limit?: number;
offset?: number;
};
/**
* Normalise les limites avant toute lecture de journal. Les imports historiques
* peuvent contenir plusieurs dizaines de milliers de lignes : l'interface ne
* doit jamais tenter de les transférer en une seule requête.
*/
export function normalizeImportLogPagination(input?: ImportLogPaginationInput) {
const requestedLimit = input?.limit ?? DEFAULT_IMPORT_LOG_PAGE_SIZE;
const requestedOffset = input?.offset ?? 0;
return {
limit: Math.min(Math.max(Math.trunc(requestedLimit), 1), MAX_IMPORT_LOG_PAGE_SIZE),
offset: Math.max(Math.trunc(requestedOffset), 0),
};
}

View File

@@ -0,0 +1,21 @@
export const IMPORT_LOG_RETENTION_OPTIONS = [3, 6, 12] as const;
export type ImportLogRetentionMonths = (typeof IMPORT_LOG_RETENTION_OPTIONS)[number];
export function isImportLogRetentionMonths(value: number): value is ImportLogRetentionMonths {
return IMPORT_LOG_RETENTION_OPTIONS.includes(value as ImportLogRetentionMonths);
}
/**
* Retourne la borne calendaire exclusive de la purge : les journaux strictement
* antérieurs à cette date peuvent être supprimés. Les factures et PDF ne sont
* jamais concernés par cette politique de rétention.
*/
export function getImportLogRetentionCutoff(
months: ImportLogRetentionMonths,
referenceDate = new Date(),
): Date {
const cutoff = new Date(referenceDate);
cutoff.setMonth(cutoff.getMonth() - months);
return cutoff;
}

View File

@@ -0,0 +1,17 @@
export type ImportResultSummary = {
importedCount: number;
duplicateCount: number;
errorCount: number;
};
/** Extrait le résultat final affiché par les services d'import. */
export function parseImportResultSummary(progress: string | null | undefined): ImportResultSummary | null {
if (!progress) return null;
const match = progress.match(/Termin[ée]\s*:\s*(\d+)\s+importée\(s\),\s*(\d+)\s+doublon\(s\)(?:,\s*(\d+)\s+erreur\(s\))?/i);
if (!match) return null;
return {
importedCount: Number(match[1]),
duplicateCount: Number(match[2]),
errorCount: Number(match[3] ?? 0),
};
}

59
shared/invoiceQuality.ts Normal file
View File

@@ -0,0 +1,59 @@
/** Champ requis configuré pour le calcul du score de qualité d’une facture. */
export type QualityFieldConfig = {
fieldName: string;
isRequired: number;
};
/** Référentiel utilisé tant qu’un compte ne possède pas encore sa configuration en base. */
export const DEFAULT_REQUIRED_INVOICE_FIELDS = [
"supplierName",
"invoiceNumber",
"invoiceDate",
"totalAmount",
] as const;
function hasValue(value: unknown): boolean {
if (value === null || value === undefined) return false;
if (typeof value === "string") return value.trim().length > 0;
if (value instanceof Date) return Number.isFinite(value.getTime());
return true;
}
/**
* Recalcule le score selon les champs obligatoires configurés pour le compte.
* Les champs optionnels n'affectent jamais le résultat. Le calcul est purement
* déterministe afin d'être identique après import et après une correction manuelle.
*/
export function calculateInvoiceQualityScore(
invoice: Record<string, unknown>,
fieldsConfiguration: readonly QualityFieldConfig[],
): number {
const configuredRequiredFields = fieldsConfiguration
.filter((field) => field.isRequired === 1)
.map((field) => field.fieldName);
const requiredFields = fieldsConfiguration.length > 0
? configuredRequiredFields
: DEFAULT_REQUIRED_INVOICE_FIELDS;
if (requiredFields.length === 0) return 100;
const completedFields = requiredFields.filter((fieldName) => hasValue(invoice[fieldName])).length;
return Math.round((completedFields / requiredFields.length) * 100);
}
/**
* Prépare une extraction IA pour la persistance. Le score brut du modèle est
* volontairement remplacé par le calcul déterministe commun à tous les flux.
*/
export function withCalculatedInvoiceQuality<T extends object>(
invoice: T,
fieldsConfiguration: readonly QualityFieldConfig[],
): T & { qualityScore: number } {
return {
...invoice,
qualityScore: calculateInvoiceQualityScore(
invoice as Record<string, unknown>,
fieldsConfiguration,
),
};
}

View File

@@ -0,0 +1,13 @@
/**
* Autorise une nouvelle analyse uniquement lorsqu'une source existante du même
* utilisateur ne possède plus aucune facture, par exemple après une purge.
* Une source contenant déjà au moins une facture reste protégée contre les
* doublons, y compris entre comptes distincts.
*/
export function shouldReprocessEmptySource(input: {
sourceUserId: number;
requestingUserId: number;
invoiceCount: number;
}): boolean {
return input.sourceUserId === input.requestingUserId && input.invoiceCount === 0;
}

24
shared/webPortalUrl.ts Normal file
View File

@@ -0,0 +1,24 @@
/**
* Ajoute HTTPS aux adresses de portail saisies sous forme de domaine simple.
* Les protocoles explicites sont conservés afin que la validation puisse refuser
* proprement ceux qui ne sont pas HTTP(S).
*/
export function normalizeWebPortalUrl(value: string): string {
const trimmed = value.trim();
if (!trimmed) return "";
if (trimmed.startsWith("//")) return `https:${trimmed}`;
if (/^[a-z][a-z\d+.-]*:\/\//i.test(trimmed) || /^(?:mailto|tel|data):/i.test(trimmed)) {
return trimmed;
}
return `https://${trimmed}`;
}
/** Vérifie qu’une URL de portail est une adresse HTTP(S) avec un hôte. */
export function isValidWebPortalUrl(value: string): boolean {
try {
const url = new URL(value);
return (url.protocol === "https:" || url.protocol === "http:") && Boolean(url.hostname);
} catch {
return false;
}
}

115
todo.md
View File

@@ -1,5 +1,114 @@
# Invoice Analyzer - TODO
## Purge d’historique par rétention — 09/10/2026
- [x] Ajouter une purge administrateur pour les journaux antérieurs à 3 mois, 6 mois ou 1 an
- [x] Afficher le volume concerné et exiger une confirmation avant suppression
- [ ] Valider la purge ciblée en recette sans supprimer de factures ni de PDF
## Incident historique et rapport d’import — production — 09/10/2026
- [x] Tracer les requêtes et données : 101 886 journaux sont renvoyés sans limite à l’interface
- [x] Borner et paginer la récupération des journaux sans supprimer l’historique
- [ ] Valider l’historique et le rapport en recette puis en production
## Cohérence export / BAP — production — 09/10/2026
- [x] Tracer les états : Amazon et Inmac ont été exportées par le flux PDF générique le 30/09, sans BAP validé ni historique BAP
- [x] Bloquer tout nouvel export PDF hors abonnement avant validation BAP et rendre l’état historique explicite dans l’interface
- [ ] Valider la correction en recette puis promouvoir en production sans altérer les PDF ni l’historique existant
## Incident import LIFEN — production — 09/10/2026
- [x] Tracer l’import : PDF stocké, échec lors de l’invocation Référentiel IA (HTTP 400) avant la réponse du modèle
- [x] Identifier la cause : le contrat Référentiel IA exige `engineSlug`, absent de l’invocation ; ajouter le routage et le test de non-régression
- [x] Déployer le routage corrigé en recette et production, contrôler les conteneurs, les routes et les volumes PDF
- [x] Permettre le retraitement production d’une source en erreur sans facture, actuellement bloquée à tort comme doublon
- [x] Retraiter la facture LIFEN via le flux applicatif : 1 facture créée, score 100, statut terminé ; santé et volume PDF validés
## Incident Santino rapide — recette — 02/10/2026
- [x] Tracer la réponse de Santino rapide : génération tronquée pendant `extractedText`, sans fermeture JSON
- [x] Confirmer la limite de sortie : SANTINO Rapide interrompt à 512 tokens (`finishReason: length`)
- [x] Réduire la sortie IA au JSON métier et conserver le texte PDF localement
- [x] Déployer et valider une extraction réelle par SANTINO Rapide en recette
## Réimport après suppression — recette — 02/10/2026
- [x] Retirer le blocage de réimport des factures supprimées dans tous les flux
- [x] Valider qu’une facture supprimée peut être réimportée sans être déclarée doublon
## Incident affichage après import — recette — 02/10/2026
- [x] Contrôler la facture importée, les filtres actifs et l’identité de session : suppression suivie d’une liste noire résiduelle
- [x] Restaurer la facture demandée : 1 facture créée, score 100, statut terminé
- [ ] Déployer l’explication explicite des imports sans facture créée et vérifier la liste recette
## Correction score de qualité à l’import — recette — 02/10/2026
- [x] Auditer les flux d’extraction IA et aligner upload, e-mail, dossier et connecteur web sur le recalcul déterministe
- [x] Ajouter des tests d’exécution du calcul partagé utilisé par chaque flux d’import
- [x] Valider le score de la facture J6LNGDAL0021 et déployer en recette
- [x] Ajouter des tests d’exécution nommés pour chaque flux et rejouer l’import manuel en recette sans SQL de score
## Incident import manuel de PDF — recette — 02/10/2026
- [x] Tracer l’import du PDF soumis et identifier le point de blocage
- [x] Corriger le traitement des caractères de contrôle dans la réponse IA
- [x] Valider le retraitement complet du PDF déjà soumis en recette
- [x] Valider un upload manuel isolé avec une copie de test, puis supprimer toutes ses traces
## Régression signalée d’import manuel — recette — 02/10/2026
- [x] Tracer le dernier import après purge : les sources PDF et leur empreinte sont restées en base
- [x] Corriger la réanalyse des sources du même utilisateur restées vides après purge
- [x] Retirer l’entrée de liste noire résiduelle correspondant au PDF explicitement réimporté
- [x] Valider la réanalyse avec le PDF concerné en recette
## Correction suppression de facture — recette — 02/10/2026
- [x] Reproduire l’échec d’archivage dans `deletedInvoices` et identifier l’écart schéma/code
- [x] Corriger la persistance d’archivage et supprimer les détails SQL de l’interface
- [x] Tester le flux tRPC réel avec une facture de test, puis vérifier son archivage et son absence persistante
- [x] Déployer uniquement en recette sans toucher aux volumes PDF
## Choix du moteur IA — Paramètres — 30/09/2026
- [x] Analyser la configuration IA locale et le contrat du Référentiel IA
- [x] Ajouter le choix entre moteur local et moteur sélectionné dans le Référentiel IA
- [x] Valider la migration, les tests, TypeScript et le build en sandbox
- [x] Créer un jeton applicatif limité pour Démat. Facturation DSI, monter le secret Docker en lecture seule et configurer la passerelle Référentiel IA en recette
- [x] Vérifier l’autorisation et le moteur recommandé par le Référentiel IA sans envoyer de facture
- [x] Vérifier une invocation contrôlée via la passerelle sans utiliser de document métier
- [ ] Vérifier une extraction réelle via la passerelle et sa traçabilité dans le Référentiel IA
## Affichage du moteur Référentiel IA — recette — 02/10/2026
- [x] Afficher le moteur et le modèle recommandés dès la sélection du Référentiel IA
- [ ] Valider l’affichage, puis déployer en recette sans toucher à MySQL ni au volume PDF
## Correction connexion Référentiel IA — recette — 02/10/2026
- [x] Identifier le repli bloqué par une variable de jeton vide dans le conteneur
- [x] Corriger la lecture du secret Docker lorsqu’une variable vide est présente
- [x] Valider et déployer le correctif en recette sans toucher à MySQL ni au volume PDF
## Import manuel des connecteurs web — 30/09/2026
- [x] Analyser le flux serveur d’import web et définir le déclenchement manuel sécurisé
- [x] Ajouter l’API de déclenchement, le suivi de résultat et le bouton avec confirmation
- [ ] Diagnostiquer l’échec réel du connecteur SFR en recette, puis déployer uniquement après validation
## Incident création connecteur web — nouvelle tentative production — 30/09/2026
- [x] Reproduire l’insertion avec le même profil utilisateur sans exposer d’identifiant
- [ ] Journaliser un code technique non sensible en cas de nouvel échec SQL
- [ ] Valider et déployer l’instrumentation sans paramètres sensibles
## Correction création connecteur web en production — 30/09/2026
- [x] Diagnostiquer l’échec SQL d’insertion sans exposer les identifiants
- [x] Corriger le schéma ou la persistance du connecteur web
- [x] Valider de bout en bout la création tRPC, avec connecteur fictif supprimé après le test
## Correction validation connecteur web — 30/09/2026
- [x] Reproduire l’erreur `portalUrl` invalide et identifier l’écart formulaire/API
- [x] Corriger la normalisation et la validation de l’URL du portail
- [x] Valider les tests, puis déployer en recette et production sans toucher à MySQL ni aux volumes
## Diagnostic réception e-mail automatique — 24/09/2026
- [x] Reproduire le démarrage de la réception automatique et identifier le blocage interface → API → planificateur
- [x] Corriger la cause sans lancer d’import ni créer de planification non sollicitée, avec sélection explicite du compte source pour les administrateurs
- [x] Valider les contrôles, les tests et le comportement de démarrage/arrêt
## Diagnostic démarrage OAuth2 après test IMAP concluant — 24/09/2026
- [x] Reproduire le démarrage OAuth2 sans importer de facture et tracer le résultat API : la réception automatique est désactivée en production pour le compte configuré
- [ ] Corriger la cause du message générique de démarrage
- [ ] Vérifier le démarrage et l’arrêt contrôlés avec les tests de non-régression
## Phase 1: Base de données et schéma
- [x] Créer table users avec support authentification locale + Azure AD
- [x] Créer table sourceFiles pour fichiers PDF uploadés
@@ -918,3 +1027,9 @@
- [x] Résoudre une destination par facture d’abonnement sélectionnée selon les règles actives
- [x] Préserver les paramètres globaux comme repli lorsqu’aucune règle ne correspond
- [x] Ajouter les tests de priorité et de regroupement des exports d’abonnements
## Déploiement des règles d’export d’abonnements
- [x] Synchroniser la version validée et reconstruire le service applicatif de recette
- [x] Vérifier en recette les routes, le conteneur, le volume PDF et le flux de résolution par facture
- [x] Synchroniser la même version et reconstruire le service applicatif de production
- [x] Vérifier en production les routes, le conteneur, le volume PDF et le dashboard associé