From 3ae1e47ca6635fda7274ed6ca7697b5bd042523c Mon Sep 17 00:00:00 2001 From: Manus Date: Sun, 12 Apr 2026 14:54:29 -0400 Subject: [PATCH] =?UTF-8?q?Checkpoint:=20Bouton=20Relancer=20corrig=C3=A9?= =?UTF-8?q?=20(automatismes=20uniquement,=20sans=20LLM).=20Syst=C3=A8me=20?= =?UTF-8?q?d'apprentissage=20complet=20:=20table=20invoiceLearnings,=20rou?= =?UTF-8?q?tes=20tRPC,=20d=C3=A9clenchement=20depuis=20InvoiceDetail,=20ap?= =?UTF-8?q?plication=20lors=20des=20imports,=20page=20LearningSettings=20d?= =?UTF-8?q?ans=20Configuration=20>=20Apprentissages=20IA.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- client/src/App.tsx | 2 + client/src/components/DashboardLayout.tsx | 3 +- client/src/pages/InvoiceDetail.tsx | 38 + client/src/pages/LearningSettings.tsx | 248 +++ drizzle/0018_warm_changeling.sql | 12 + drizzle/meta/0018_snapshot.json | 1694 +++++++++++++++++++++ drizzle/meta/_journal.json | 7 + drizzle/schema.ts | 25 + server/db.ts | 82 +- server/routers.ts | 150 +- 10 files changed, 2187 insertions(+), 74 deletions(-) create mode 100644 client/src/pages/LearningSettings.tsx create mode 100644 drizzle/0018_warm_changeling.sql create mode 100644 drizzle/meta/0018_snapshot.json diff --git a/client/src/App.tsx b/client/src/App.tsx index 9602d46..a944812 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -18,6 +18,7 @@ import Users from "./pages/Users"; import ListsAdmin from "./pages/ListsAdmin"; import AutomationRules from "./pages/AutomationRules"; import BapHistory from "./pages/BapHistory"; +import LearningSettings from "./pages/LearningSettings"; function Router() { return ( @@ -36,6 +37,7 @@ function Router() { + diff --git a/client/src/components/DashboardLayout.tsx b/client/src/components/DashboardLayout.tsx index b96d20c..05b45e1 100644 --- a/client/src/components/DashboardLayout.tsx +++ b/client/src/components/DashboardLayout.tsx @@ -25,7 +25,7 @@ import { import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible"; import { getLoginUrl } from "@/const"; import { useIsMobile } from "@/hooks/useMobile"; -import { LayoutDashboard, LogOut, PanelLeft, Users, FileText, Upload, History, Settings, Download, List, Zap, ChevronDown, Receipt, Cog, ClipboardList, CheckSquare } from "lucide-react"; +import { LayoutDashboard, LogOut, PanelLeft, Users, FileText, Upload, History, Settings, Download, List, Zap, ChevronDown, Receipt, Cog, ClipboardList, CheckSquare, Brain } from "lucide-react"; import { CSSProperties, useEffect, useRef, useState } from "react"; import { useLocation } from "wouter"; import { DashboardLayoutSkeleton } from './DashboardLayoutSkeleton'; @@ -66,6 +66,7 @@ const menuStructure: MenuItem[] = [ { icon: Download, label: "Paramètres import / export", path: "/import-settings" }, { icon: List, label: "Administration des listes", path: "/lists-admin" }, { icon: Zap, label: "Automatismes", path: "/automation-rules" }, + { icon: Brain, label: "Apprentissages IA", path: "/learning-settings" }, { icon: Users, label: "Utilisateurs", path: "/users", adminOnly: true }, ], }, diff --git a/client/src/pages/InvoiceDetail.tsx b/client/src/pages/InvoiceDetail.tsx index b858f5c..a69ea4e 100644 --- a/client/src/pages/InvoiceDetail.tsx +++ b/client/src/pages/InvoiceDetail.tsx @@ -54,6 +54,8 @@ export default function InvoiceDetail() { } }, [invoice]); + const upsertLearningMutation = trpc.learnings.upsert.useMutation(); + const updateMutation = trpc.invoices.update.useMutation({ onSuccess: () => { toast.success("Facture mise à jour avec succès"); @@ -80,6 +82,42 @@ export default function InvoiceDetail() { 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) { + const supplierName = formData.supplierName || invoice.supplierName || ""; + if (!supplierName) return; + + // isSubscription : géré séparément via le toggle dans InvoicesBAP + // typeAchat + if (formData.typeAchat !== (invoice.typeAchat || "")) { + upsertLearningMutation.mutate({ + supplierName, + fieldName: "typeAchat", + originalValue: invoice.typeAchat || undefined, + correctedValue: formData.typeAchat, + }); + } + // serviceConcerne + if (formData.serviceConcerne !== (invoice.serviceConcerne || "")) { + upsertLearningMutation.mutate({ + supplierName, + fieldName: "serviceConcerne", + originalValue: invoice.serviceConcerne || undefined, + correctedValue: formData.serviceConcerne, + }); + } + // ventilationComptable + if (formData.ventilationComptable !== (invoice.ventilationComptable || "")) { + upsertLearningMutation.mutate({ + supplierName, + fieldName: "ventilationComptable", + originalValue: invoice.ventilationComptable || undefined, + correctedValue: formData.ventilationComptable, + }); + } + } + updateMutation.mutate({ id: invoiceId, data: { diff --git a/client/src/pages/LearningSettings.tsx b/client/src/pages/LearningSettings.tsx new file mode 100644 index 0000000..9321b99 --- /dev/null +++ b/client/src/pages/LearningSettings.tsx @@ -0,0 +1,248 @@ +import { useState } from "react"; +import DashboardLayout from "@/components/DashboardLayout"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import { trpc } from "@/lib/trpc"; +import { toast } from "sonner"; +import { Brain, Trash2, RefreshCw, AlertTriangle } from "lucide-react"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from "@/components/ui/alert-dialog"; + +const FIELD_LABELS: Record = { + typeAchat: "Type d'achat", + serviceConcerne: "Service concerné", + ventilationComptable: "Ventilation comptable", + isSubscription: "Abonnement", +}; + +const FIELD_COLORS: Record = { + typeAchat: "bg-blue-100 text-blue-800", + serviceConcerne: "bg-purple-100 text-purple-800", + ventilationComptable: "bg-green-100 text-green-800", + isSubscription: "bg-orange-100 text-orange-800", +}; + +export default function LearningSettings() { + const utils = trpc.useUtils(); + const { data: learnings, isLoading } = trpc.learnings.list.useQuery(); + + const deleteMutation = trpc.learnings.delete.useMutation({ + onSuccess: () => { + toast.success("Apprentissage supprimé"); + utils.learnings.list.invalidate(); + }, + onError: () => toast.error("Erreur lors de la suppression"), + }); + + const deleteAllMutation = trpc.learnings.deleteAll.useMutation({ + onSuccess: () => { + toast.success("Tous les apprentissages ont été supprimés"); + utils.learnings.list.invalidate(); + }, + onError: () => toast.error("Erreur lors de la suppression"), + }); + + const grouped = (learnings || []).reduce>((acc, l) => { + if (!l) return acc; + const key = l.supplierKey; + if (!acc[key]) acc[key] = []; + acc[key]!.push(l); + return acc; + }, {}); + + const supplierCount = Object.keys(grouped).length; + const totalCount = learnings?.length || 0; + + return ( + +
+ {/* Header */} +
+
+
+ +
+
+

Apprentissages du système

+

+ Corrections manuelles mémorisées et appliquées automatiquement aux prochains imports +

+
+
+ {totalCount > 0 && ( + + + + + + + Supprimer tous les apprentissages ? + + Cette action supprimera les {totalCount} apprentissage(s) mémorisé(s). Le système ne pourra plus + appliquer automatiquement ces corrections lors des prochains imports. + + + + Annuler + deleteAllMutation.mutate()} + > + Supprimer tout + + + + + )} +
+ + {/* Stats */} +
+ + +
{supplierCount}
+
Fournisseur(s) appris
+
+
+ + +
{totalCount}
+
Correction(s) mémorisée(s)
+
+
+
+ + {/* Comment ça fonctionne */} + + + + + Comment fonctionne l'apprentissage ? + + + +

+ Quand vous modifiez manuellement le type d'achat, le service concerné ou + la ventilation comptable d'une facture dans sa page de détail, le système mémorise cette + correction pour ce fournisseur. +

+

+ Lors des prochains imports, si une facture du même fournisseur est détectée, les corrections mémorisées + sont appliquées automatiquement après l'extraction IA et les règles d'automatisme. +

+
+
+ + {/* Liste des apprentissages */} + {isLoading ? ( +
+ + Chargement... +
+ ) : totalCount === 0 ? ( + + + +

Aucun apprentissage enregistré

+

+ Modifiez manuellement les champs d'une facture (type d'achat, service, ventilation) pour que le système + commence à apprendre. +

+
+
+ ) : ( +
+ {Object.entries(grouped).map(([supplierKey, entries]) => ( + + +
+ {supplierKey} + + {entries?.length} correction(s) + +
+
+ +
+ {entries?.map((learning) => ( +
+
+ + {FIELD_LABELS[learning.fieldName] || learning.fieldName} + +
+ {learning.originalValue && ( + <> + + {learning.originalValue} + + + + )} + + {learning.correctedValue || vide} + +
+
+
+ + Appliqué {learning.applyCount}× + + + + + + + + Supprimer cet apprentissage ? + + La correction « {FIELD_LABELS[learning.fieldName] || learning.fieldName} → {learning.correctedValue} » + pour le fournisseur « {supplierKey} » ne sera plus appliquée automatiquement. + + + + Annuler + deleteMutation.mutate({ id: learning.id })} + > + Supprimer + + + + +
+
+ ))} +
+
+
+ ))} +
+ )} +
+
+ ); +} diff --git a/drizzle/0018_warm_changeling.sql b/drizzle/0018_warm_changeling.sql new file mode 100644 index 0000000..96b2c88 --- /dev/null +++ b/drizzle/0018_warm_changeling.sql @@ -0,0 +1,12 @@ +CREATE TABLE `invoiceLearnings` ( + `id` int AUTO_INCREMENT NOT NULL, + `userId` int NOT NULL, + `supplierKey` varchar(255) NOT NULL, + `fieldName` varchar(100) NOT NULL, + `originalValue` varchar(255), + `correctedValue` varchar(255) NOT NULL, + `applyCount` int NOT NULL DEFAULT 1, + `createdAt` timestamp NOT NULL DEFAULT (now()), + `updatedAt` timestamp NOT NULL DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP, + CONSTRAINT `invoiceLearnings_id` PRIMARY KEY(`id`) +); diff --git a/drizzle/meta/0018_snapshot.json b/drizzle/meta/0018_snapshot.json new file mode 100644 index 0000000..ed273a5 --- /dev/null +++ b/drizzle/meta/0018_snapshot.json @@ -0,0 +1,1694 @@ +{ + "version": "5", + "dialect": "mysql", + "id": "0416ab21-caac-4ff7-a65a-6cee265eabba", + "prevId": "3bc673f2-4726-44ab-b368-3d3e93107a78", + "tables": { + "accountingAllocationList": { + "name": "accountingAllocationList", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "userId": { + "name": "userId", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + } + }, + "indexes": { + "user_allocation_unique": { + "name": "user_allocation_unique", + "columns": [ + "userId", + "name" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "accountingAllocationList_id": { + "name": "accountingAllocationList_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "automationRules": { + "name": "automationRules", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "userId": { + "name": "userId", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "isActive": { + "name": "isActive", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "priority": { + "name": "priority", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "conditions": { + "name": "conditions", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "conditionsLogic": { + "name": "conditionsLogic", + "type": "enum('AND','OR')", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'AND'" + }, + "actions": { + "name": "actions", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "automationRules_id": { + "name": "automationRules_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "bapHistory": { + "name": "bapHistory", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "userId": { + "name": "userId", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "invoiceId": { + "name": "invoiceId", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "supplierName": { + "name": "supplierName", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "invoiceNumber": { + "name": "invoiceNumber", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "invoiceDate": { + "name": "invoiceDate", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "totalAmount": { + "name": "totalAmount", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "typeAchat": { + "name": "typeAchat", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "serviceConcerne": { + "name": "serviceConcerne", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "ventilationComptable": { + "name": "ventilationComptable", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "recipientName": { + "name": "recipientName", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "exportMode": { + "name": "exportMode", + "type": "enum('browser','folder')", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'browser'" + }, + "exportPath": { + "name": "exportPath", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "pdfUrl": { + "name": "pdfUrl", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "signatureName": { + "name": "signatureName", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "validatedAt": { + "name": "validatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "bapHistory_id": { + "name": "bapHistory_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "departmentList": { + "name": "departmentList", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "userId": { + "name": "userId", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + } + }, + "indexes": { + "user_department_unique": { + "name": "user_department_unique", + "columns": [ + "userId", + "name" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "departmentList_id": { + "name": "departmentList_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "importLogs": { + "name": "importLogs", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "userId": { + "name": "userId", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sourceFileId": { + "name": "sourceFileId", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "fileName": { + "name": "fileName", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "totalInvoicesDetected": { + "name": "totalInvoicesDetected", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "invoicesImported": { + "name": "invoicesImported", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "duplicatesIgnored": { + "name": "duplicatesIgnored", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "errors": { + "name": "errors", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "duplicateDetails": { + "name": "duplicateDetails", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "errorDetails": { + "name": "errorDetails", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "importedAt": { + "name": "importedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "importLogs_id": { + "name": "importLogs_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "importSettings": { + "name": "importSettings", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "userId": { + "name": "userId", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "manualImportEnabled": { + "name": "manualImportEnabled", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "autoImportEnabled": { + "name": "autoImportEnabled", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "autoImportSourcePath": { + "name": "autoImportSourcePath", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "autoImportFrequency": { + "name": "autoImportFrequency", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 60 + }, + "emailImportEnabled": { + "name": "emailImportEnabled", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "emailImportAddress": { + "name": "emailImportAddress", + "type": "varchar(320)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "emailImportPassword": { + "name": "emailImportPassword", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "emailImportHost": { + "name": "emailImportHost", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "emailImportPort": { + "name": "emailImportPort", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 993 + }, + "emailImportFrequency": { + "name": "emailImportFrequency", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 30 + }, + "exportFolder": { + "name": "exportFolder", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "bapExportMode": { + "name": "bapExportMode", + "type": "enum('browser','folder')", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'browser'" + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "importSettings_id": { + "name": "importSettings_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": { + "importSettings_userId_unique": { + "name": "importSettings_userId_unique", + "columns": [ + "userId" + ] + } + }, + "checkConstraint": {} + }, + "invoiceLearnings": { + "name": "invoiceLearnings", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "userId": { + "name": "userId", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "supplierKey": { + "name": "supplierKey", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "fieldName": { + "name": "fieldName", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "originalValue": { + "name": "originalValue", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "correctedValue": { + "name": "correctedValue", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "applyCount": { + "name": "applyCount", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "invoiceLearnings_id": { + "name": "invoiceLearnings_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "invoices": { + "name": "invoices", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "userId": { + "name": "userId", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sourceFileId": { + "name": "sourceFileId", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "invoiceIndexInFile": { + "name": "invoiceIndexInFile", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "fileName": { + "name": "fileName", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "fileKey": { + "name": "fileKey", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "fileUrl": { + "name": "fileUrl", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "supplierName": { + "name": "supplierName", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "invoiceNumber": { + "name": "invoiceNumber", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "invoiceDate": { + "name": "invoiceDate", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "deliveryNoteNumber": { + "name": "deliveryNoteNumber", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "orderNumber": { + "name": "orderNumber", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "totalAmount": { + "name": "totalAmount", + "type": "decimal(10,2)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "recipientName": { + "name": "recipientName", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "pageRange": { + "name": "pageRange", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "qualityScore": { + "name": "qualityScore", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "metadataFileKey": { + "name": "metadataFileKey", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "metadataFileUrl": { + "name": "metadataFileUrl", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "enum('processing','completed','error')", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'processing'" + }, + "errorMessage": { + "name": "errorMessage", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "exportStatus": { + "name": "exportStatus", + "type": "enum('not_exported','exported','export_error')", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'not_exported'" + }, + "manuallyEdited": { + "name": "manuallyEdited", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "serviceConcerne": { + "name": "serviceConcerne", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "typeAchat": { + "name": "typeAchat", + "type": "enum('CAPEX','OPEX')", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "ventilationComptable": { + "name": "ventilationComptable", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "autoFilledFields": { + "name": "autoFilledFields", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "extractedText": { + "name": "extractedText", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "isSubscription": { + "name": "isSubscription", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "bapValidated": { + "name": "bapValidated", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "bapValidatedAt": { + "name": "bapValidatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "exportedAt": { + "name": "exportedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "exportMode": { + "name": "exportMode", + "type": "enum('manual','automatic')", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": { + "supplier_invoice_date_unique": { + "name": "supplier_invoice_date_unique", + "columns": [ + "supplierName", + "invoiceNumber", + "invoiceDate" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "invoices_id": { + "name": "invoices_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "llmFieldsConfig": { + "name": "llmFieldsConfig", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "userId": { + "name": "userId", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "fieldName": { + "name": "fieldName", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "displayName": { + "name": "displayName", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "isRequired": { + "name": "isRequired", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "displayOrder": { + "name": "displayOrder", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "llmFieldsConfig_id": { + "name": "llmFieldsConfig_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "llmLogs": { + "name": "llmLogs", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "userId": { + "name": "userId", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sourceFileId": { + "name": "sourceFileId", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "invoiceId": { + "name": "invoiceId", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "operation": { + "name": "operation", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "model": { + "name": "model", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "promptSent": { + "name": "promptSent", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "rawResponse": { + "name": "rawResponse", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "cleanedResponse": { + "name": "cleanedResponse", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "success": { + "name": "success", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "errorMessage": { + "name": "errorMessage", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "processingTimeMs": { + "name": "processingTimeMs", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "pageRange": { + "name": "pageRange", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "llmLogs_id": { + "name": "llmLogs_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "serviceSignatures": { + "name": "serviceSignatures", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "userId": { + "name": "userId", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "serviceName": { + "name": "serviceName", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "signatureId": { + "name": "signatureId", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": { + "user_service_unique": { + "name": "user_service_unique", + "columns": [ + "userId", + "serviceName" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "serviceSignatures_id": { + "name": "serviceSignatures_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "signatures": { + "name": "signatures", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "userId": { + "name": "userId", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "firstName": { + "name": "firstName", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "lastName": { + "name": "lastName", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "imageKey": { + "name": "imageKey", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "imageUrl": { + "name": "imageUrl", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "signatures_id": { + "name": "signatures_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "sourceFiles": { + "name": "sourceFiles", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "userId": { + "name": "userId", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "fileName": { + "name": "fileName", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "fileKey": { + "name": "fileKey", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "fileUrl": { + "name": "fileUrl", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "totalInvoicesDetected": { + "name": "totalInvoicesDetected", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "processingStatus": { + "name": "processingStatus", + "type": "enum('processing','completed','error')", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'processing'" + }, + "processingProgress": { + "name": "processingProgress", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "sourceFiles_id": { + "name": "sourceFiles_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "userSettings": { + "name": "userSettings", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "userId": { + "name": "userId", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "llmModel": { + "name": "llmModel", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'mistral-large-latest'" + }, + "orderNumberFormat": { + "name": "orderNumberFormat", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "invoiceNumberKeywords": { + "name": "invoiceNumberKeywords", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "deliveryNoteKeywords": { + "name": "deliveryNoteKeywords", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "orderNumberKeywords": { + "name": "orderNumberKeywords", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "supplierKeywords": { + "name": "supplierKeywords", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "totalAmountKeywords": { + "name": "totalAmountKeywords", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "subscriptionKeywords": { + "name": "subscriptionKeywords", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "recipientKeywords": { + "name": "recipientKeywords", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sftpRecipientFilter": { + "name": "sftpRecipientFilter", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sftpHost": { + "name": "sftpHost", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sftpPort": { + "name": "sftpPort", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 22 + }, + "sftpUsername": { + "name": "sftpUsername", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sftpPassword": { + "name": "sftpPassword", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sftpRemotePath": { + "name": "sftpRemotePath", + "type": "varchar(500)", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'/'" + }, + "sftpAutoExport": { + "name": "sftpAutoExport", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "llmLogsRetentionMonths": { + "name": "llmLogsRetentionMonths", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 3 + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "userSettings_id": { + "name": "userSettings_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": { + "userSettings_userId_unique": { + "name": "userSettings_userId_unique", + "columns": [ + "userId" + ] + } + }, + "checkConstraint": {} + }, + "users": { + "name": "users", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "openId": { + "name": "openId", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "azureAdId": { + "name": "azureAdId", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "varchar(320)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "passwordHash": { + "name": "passwordHash", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "loginMethod": { + "name": "loginMethod", + "type": "enum('manus','local','azure-ad')", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "enum('user','admin')", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'user'" + }, + "isActive": { + "name": "isActive", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + }, + "lastSignedIn": { + "name": "lastSignedIn", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "users_id": { + "name": "users_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": { + "users_openId_unique": { + "name": "users_openId_unique", + "columns": [ + "openId" + ] + }, + "users_azureAdId_unique": { + "name": "users_azureAdId_unique", + "columns": [ + "azureAdId" + ] + }, + "users_email_unique": { + "name": "users_email_unique", + "columns": [ + "email" + ] + } + }, + "checkConstraint": {} + } + }, + "views": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "tables": {}, + "indexes": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 370618e..a0ee322 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -127,6 +127,13 @@ "when": 1775987314108, "tag": "0017_clammy_toad", "breakpoints": true + }, + { + "idx": 18, + "version": "5", + "when": 1776019747088, + "tag": "0018_warm_changeling", + "breakpoints": true } ] } \ No newline at end of file diff --git a/drizzle/schema.ts b/drizzle/schema.ts index 5a5f7ba..d8d8b47 100644 --- a/drizzle/schema.ts +++ b/drizzle/schema.ts @@ -372,3 +372,28 @@ export const bapHistory = mysqlTable("bapHistory", { }); export type BapHistory = typeof bapHistory.$inferSelect; export type InsertBapHistory = typeof bapHistory.$inferInsert; + +/** + * Invoice learnings table — corrections manuelles apprises par le système + * Quand l'utilisateur modifie un champ détecté par le LLM, le système mémorise + * la correction pour l'appliquer automatiquement aux prochains imports similaires. + */ +export const invoiceLearnings = mysqlTable("invoiceLearnings", { + id: int("id").autoincrement().primaryKey(), + userId: int("userId").notNull(), + /** Clé de correspondance : fournisseur normalisé (minuscules, sans espaces superflus) */ + supplierKey: varchar("supplierKey", { length: 255 }).notNull(), + /** Champ corrigé : 'isSubscription' | 'typeAchat' | 'serviceConcerne' | 'ventilationComptable' */ + fieldName: varchar("fieldName", { length: 100 }).notNull(), + /** Valeur originale détectée par le LLM (pour affichage dans la page de gestion) */ + originalValue: varchar("originalValue", { length: 255 }), + /** Valeur corrigée manuellement par l'utilisateur */ + correctedValue: varchar("correctedValue", { length: 255 }).notNull(), + /** Nombre de fois que cette correction a été appliquée */ + applyCount: int("applyCount").default(1).notNull(), + createdAt: timestamp("createdAt").defaultNow().notNull(), + updatedAt: timestamp("updatedAt").defaultNow().onUpdateNow().notNull(), +}); + +export type InvoiceLearning = typeof invoiceLearnings.$inferSelect; +export type InsertInvoiceLearning = typeof invoiceLearnings.$inferInsert; diff --git a/server/db.ts b/server/db.ts index 62303be..6e91aa5 100644 --- a/server/db.ts +++ b/server/db.ts @@ -41,7 +41,10 @@ import { ServiceSignature, bapHistory, InsertBapHistory, - BapHistory + BapHistory, + invoiceLearnings, + InsertInvoiceLearning, + InvoiceLearning } from "../drizzle/schema"; import { ENV } from './_core/env'; @@ -811,3 +814,80 @@ export async function deleteBapHistoryEntry(id: number): Promise { if (!db) return; await db.delete(bapHistory).where(eq(bapHistory.id, id)); } + +// ── Invoice Learnings (corrections manuelles apprises) ──────────────────────── + +/** Normalise le nom du fournisseur pour la clé de correspondance */ +export function normalizeSupplierId(supplierName: string): string { + return supplierName.trim().toLowerCase().replace(/\s+/g, ' '); +} + +/** Récupère tous les apprentissages d'un utilisateur */ +export async function getLearningsByUser(userId: number): Promise { + const db = await getDb(); + if (!db) return []; + return db.select().from(invoiceLearnings).where(eq(invoiceLearnings.userId, userId)); +} + +/** Récupère les apprentissages pour un fournisseur donné */ +export async function getLearningsBySupplier(userId: number, supplierName: string): Promise { + const db = await getDb(); + if (!db) return []; + const key = normalizeSupplierId(supplierName); + return db.select().from(invoiceLearnings).where( + and(eq(invoiceLearnings.userId, userId), eq(invoiceLearnings.supplierKey, key)) + ); +} + +/** Enregistre ou met à jour un apprentissage (upsert par userId + supplierKey + fieldName) */ +export async function upsertLearning(data: { + userId: number; + supplierName: string; + fieldName: string; + originalValue?: string; + correctedValue: string; +}): Promise { + const db = await getDb(); + if (!db) return; + const supplierKey = normalizeSupplierId(data.supplierName); + // Chercher si une entrée existe déjà + const existing = await db.select().from(invoiceLearnings).where( + and( + eq(invoiceLearnings.userId, data.userId), + eq(invoiceLearnings.supplierKey, supplierKey), + eq(invoiceLearnings.fieldName, data.fieldName) + ) + ); + if (existing.length > 0) { + await db.update(invoiceLearnings) + .set({ + correctedValue: data.correctedValue, + originalValue: data.originalValue ?? existing[0].originalValue, + applyCount: (existing[0].applyCount || 1) + 1, + }) + .where(eq(invoiceLearnings.id, existing[0].id)); + } else { + await db.insert(invoiceLearnings).values({ + userId: data.userId, + supplierKey, + fieldName: data.fieldName, + originalValue: data.originalValue, + correctedValue: data.correctedValue, + applyCount: 1, + }); + } +} + +/** Supprime un apprentissage par ID */ +export async function deleteLearning(id: number): Promise { + const db = await getDb(); + if (!db) return; + await db.delete(invoiceLearnings).where(eq(invoiceLearnings.id, id)); +} + +/** Supprime tous les apprentissages d'un utilisateur */ +export async function deleteAllLearnings(userId: number): Promise { + const db = await getDb(); + if (!db) return; + await db.delete(invoiceLearnings).where(eq(invoiceLearnings.userId, userId)); +} diff --git a/server/routers.ts b/server/routers.ts index 9674cb0..9ef523e 100644 --- a/server/routers.ts +++ b/server/routers.ts @@ -66,6 +66,11 @@ import { createBapHistoryEntry, getBapHistoryByUser, deleteBapHistoryEntry, + getLearningsByUser, + getLearningsBySupplier, + upsertLearning, + deleteLearning, + deleteAllLearnings, } from "./db"; import { loginLocal, hashPassword, getAzureAuthUrl, isAzureAdConfigured, generateToken } from "./auth"; import { extractInvoicesWithMistral, generateMetadataJSON } from "./invoiceExtractor"; @@ -287,6 +292,28 @@ export const appRouter = router({ console.error("[Automation] Error applying rules:", autoError); // Don't fail the import if automation fails } + + // Apply learnings (corrections manuelles mémorisées) after automation rules + try { + if (newInvoice.supplierName) { + const learnings = await getLearningsBySupplier(userId, newInvoice.supplierName); + if (learnings.length > 0) { + const learningUpdates: Record = {}; + for (const learning of learnings) { + if (learning.fieldName === 'typeAchat' || learning.fieldName === 'serviceConcerne' || learning.fieldName === 'ventilationComptable') { + learningUpdates[learning.fieldName] = learning.correctedValue; + } + } + if (Object.keys(learningUpdates).length > 0) { + await updateInvoice(newInvoice.id, learningUpdates as any); + console.log(`[Learning] Applied ${Object.keys(learningUpdates).length} learning(s) to invoice ${newInvoice.id} (${newInvoice.supplierName})`); + } + } + } + } catch (learningError) { + console.error("[Learning] Error applying learnings:", learningError); + // Don't fail the import if learning application fails + } importedCount++; } catch (error: any) { @@ -870,89 +897,23 @@ export const appRouter = router({ invoiceIds: z.array(z.number()).min(1), })) .mutation(async ({ input, ctx }) => { + // Relance UNIQUEMENT les automatismes (sans re-extraction LLM) const { applyAutomationRules } = await import("./automationEngine"); - const { localStoragePut, generateStorageKey } = await import('./localStorage'); - const path = await import('path'); - const fs = await import('fs/promises'); - const STORAGE_BASE_PATH = process.env.STORAGE_BASE_PATH || path.join(process.cwd(), 'storage'); const allInvoices = await getInvoicesByUser(ctx.user.id); const selected = allInvoices.filter(inv => input.invoiceIds.includes(inv.id)); if (selected.length === 0) throw new TRPCError({ code: 'NOT_FOUND', message: 'Aucune facture trouvée' }); - const userSettings = await getUserSettings(ctx.user.id); - const model = userSettings?.llmModel || 'mistral-large-latest'; - const customKeywords = { - invoiceNumber: userSettings?.invoiceNumberKeywords || null, - deliveryNote: userSettings?.deliveryNoteKeywords || null, - orderNumber: userSettings?.orderNumberKeywords || null, - supplier: userSettings?.supplierKeywords || null, - totalAmount: userSettings?.totalAmountKeywords || null, - subscription: userSettings?.subscriptionKeywords || null, - recipient: userSettings?.recipientKeywords || null, - }; - let processed = 0; let errors = 0; - const results: Array<{ id: number; success: boolean; qualityScore?: number; error?: string }> = []; + const results: Array<{ id: number; success: boolean; error?: string }> = []; for (const invoice of selected) { try { - // Lire le PDF source - let pdfBuffer: Buffer; - const localPath = path.join(STORAGE_BASE_PATH, invoice.fileKey); - try { - pdfBuffer = await fs.readFile(localPath); - } catch (_) { - const fileUrl = invoice.fileUrl; - if (!fileUrl) throw new Error('Fichier PDF introuvable'); - let absoluteUrl = fileUrl; - if (fileUrl.startsWith('/')) { - const baseUrl = process.env.APP_BASE_URL || `http://localhost:${process.env.PORT || 3000}`; - absoluteUrl = `${baseUrl}${fileUrl}`; - } - const resp = await fetch(absoluteUrl); - if (!resp.ok) throw new Error(`HTTP ${resp.status}`); - pdfBuffer = Buffer.from(await resp.arrayBuffer()); + const automationUpdates = await applyAutomationRules(ctx.user.id, invoice); + if (Object.keys(automationUpdates).length > 0) { + await updateInvoice(invoice.id, automationUpdates); } - - // Relancer l'extraction LLM - const result = await extractInvoicesWithMistral(pdfBuffer, ctx.user.id, invoice.sourceFileId!, model, customKeywords); - - // Prendre la facture correspondante dans le résultat (par index ou la première) - const idx = (invoice.invoiceIndexInFile || 1) - 1; - const extracted = result.invoices[idx] || result.invoices[0]; - if (!extracted) throw new Error('Extraction vide'); - - // Mettre à jour les champs extraits - const metadataJson = generateMetadataJSON(extracted); - const metadataKey = generateStorageKey(ctx.user.id, `${invoice.fileName}-reprocess-metadata.json`); - const { url: metadataUrl } = await localStoragePut(metadataKey, Buffer.from(metadataJson), 'application/json'); - - await updateInvoice(invoice.id, { - supplierName: extracted.supplierName, - invoiceNumber: extracted.invoiceNumber, - invoiceDate: extracted.invoiceDate, - deliveryNoteNumber: extracted.deliveryNoteNumber, - orderNumber: extracted.orderNumber, - totalAmount: extracted.totalAmount?.toString(), - recipientName: extracted.recipientName, - qualityScore: extracted.qualityScore, - extractedText: extracted.extractedText, - isSubscription: extracted.isSubscription ? 1 : 0, - metadataFileKey: metadataKey, - metadataFileUrl: metadataUrl, - }); - - // Réappliquer les règles d'automatisme - const updatedInvoice = await getInvoiceById(invoice.id); - if (updatedInvoice) { - const automationUpdates = await applyAutomationRules(ctx.user.id, updatedInvoice); - if (Object.keys(automationUpdates).length > 0) { - await updateInvoice(invoice.id, automationUpdates); - } - } - - results.push({ id: invoice.id, success: true, qualityScore: extracted.qualityScore }); + results.push({ id: invoice.id, success: true }); processed++; } catch (err: any) { console.error(`[Reprocess] Error on invoice ${invoice.id}:`, err); @@ -1949,5 +1910,50 @@ export const appRouter = router({ return { success: true }; }), }), + + // ============= LEARNINGS ROUTES ============= + learnings: router({ + /** Liste tous les apprentissages de l'utilisateur */ + list: protectedProcedure.query(async ({ ctx }) => { + return await getLearningsByUser(ctx.user.id); + }), + + /** Enregistre ou met à jour un apprentissage suite à une correction manuelle */ + upsert: protectedProcedure + .input(z.object({ + supplierName: z.string().min(1), + fieldName: z.string().min(1), + originalValue: z.string().optional(), + correctedValue: z.string(), + })) + .mutation(async ({ input, ctx }) => { + await upsertLearning({ + userId: ctx.user.id, + supplierName: input.supplierName, + fieldName: input.fieldName, + originalValue: input.originalValue, + correctedValue: input.correctedValue, + }); + return { success: true }; + }), + + /** Supprime un apprentissage par ID */ + delete: protectedProcedure + .input(z.object({ id: z.number() })) + .mutation(async ({ input, ctx }) => { + const all = await getLearningsByUser(ctx.user.id); + const entry = all.find(l => l.id === input.id); + if (!entry) throw new TRPCError({ code: 'NOT_FOUND' }); + await deleteLearning(input.id); + return { success: true }; + }), + + /** Supprime tous les apprentissages de l'utilisateur */ + deleteAll: protectedProcedure + .mutation(async ({ ctx }) => { + await deleteAllLearnings(ctx.user.id); + return { success: true }; + }), + }), }); export type AppRouter = typeof appRouter;