Checkpoint: Bouton Relancer corrigé (automatismes uniquement, sans LLM). Système d'apprentissage complet : table invoiceLearnings, routes tRPC, déclenchement depuis InvoiceDetail, application lors des imports, page LearningSettings dans Configuration > Apprentissages IA.
This commit is contained in:
@@ -18,6 +18,7 @@ import Users from "./pages/Users";
|
|||||||
import ListsAdmin from "./pages/ListsAdmin";
|
import ListsAdmin from "./pages/ListsAdmin";
|
||||||
import AutomationRules from "./pages/AutomationRules";
|
import AutomationRules from "./pages/AutomationRules";
|
||||||
import BapHistory from "./pages/BapHistory";
|
import BapHistory from "./pages/BapHistory";
|
||||||
|
import LearningSettings from "./pages/LearningSettings";
|
||||||
|
|
||||||
function Router() {
|
function Router() {
|
||||||
return (
|
return (
|
||||||
@@ -36,6 +37,7 @@ function Router() {
|
|||||||
<Route path="/lists-admin" component={ListsAdmin} />
|
<Route path="/lists-admin" component={ListsAdmin} />
|
||||||
<Route path="/automation-rules" component={AutomationRules} />
|
<Route path="/automation-rules" component={AutomationRules} />
|
||||||
<Route path="/bap-history" component={BapHistory} />
|
<Route path="/bap-history" component={BapHistory} />
|
||||||
|
<Route path="/learning-settings" component={LearningSettings} />
|
||||||
<Route path="/404" component={NotFound} />
|
<Route path="/404" component={NotFound} />
|
||||||
<Route component={NotFound} />
|
<Route component={NotFound} />
|
||||||
</Switch>
|
</Switch>
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ import {
|
|||||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
|
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
|
||||||
import { getLoginUrl } from "@/const";
|
import { getLoginUrl } from "@/const";
|
||||||
import { useIsMobile } from "@/hooks/useMobile";
|
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 { CSSProperties, useEffect, useRef, useState } from "react";
|
||||||
import { useLocation } from "wouter";
|
import { useLocation } from "wouter";
|
||||||
import { DashboardLayoutSkeleton } from './DashboardLayoutSkeleton';
|
import { DashboardLayoutSkeleton } from './DashboardLayoutSkeleton';
|
||||||
@@ -66,6 +66,7 @@ const menuStructure: MenuItem[] = [
|
|||||||
{ icon: Download, label: "Paramètres import / export", path: "/import-settings" },
|
{ icon: Download, label: "Paramètres import / export", path: "/import-settings" },
|
||||||
{ icon: List, label: "Administration des listes", path: "/lists-admin" },
|
{ icon: List, label: "Administration des listes", path: "/lists-admin" },
|
||||||
{ icon: Zap, label: "Automatismes", path: "/automation-rules" },
|
{ icon: Zap, label: "Automatismes", path: "/automation-rules" },
|
||||||
|
{ icon: Brain, label: "Apprentissages IA", path: "/learning-settings" },
|
||||||
{ icon: Users, label: "Utilisateurs", path: "/users", adminOnly: true },
|
{ icon: Users, label: "Utilisateurs", path: "/users", adminOnly: true },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -54,6 +54,8 @@ export default function InvoiceDetail() {
|
|||||||
}
|
}
|
||||||
}, [invoice]);
|
}, [invoice]);
|
||||||
|
|
||||||
|
const upsertLearningMutation = trpc.learnings.upsert.useMutation();
|
||||||
|
|
||||||
const updateMutation = trpc.invoices.update.useMutation({
|
const updateMutation = trpc.invoices.update.useMutation({
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
toast.success("Facture mise à jour avec succès");
|
toast.success("Facture mise à jour avec succès");
|
||||||
@@ -80,6 +82,42 @@ export default function InvoiceDetail() {
|
|||||||
|
|
||||||
const newQualityScore = Math.round((filledFields / totalFields) * 100);
|
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({
|
updateMutation.mutate({
|
||||||
id: invoiceId,
|
id: invoiceId,
|
||||||
data: {
|
data: {
|
||||||
|
|||||||
248
client/src/pages/LearningSettings.tsx
Normal file
248
client/src/pages/LearningSettings.tsx
Normal file
@@ -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<string, string> = {
|
||||||
|
typeAchat: "Type d'achat",
|
||||||
|
serviceConcerne: "Service concerné",
|
||||||
|
ventilationComptable: "Ventilation comptable",
|
||||||
|
isSubscription: "Abonnement",
|
||||||
|
};
|
||||||
|
|
||||||
|
const FIELD_COLORS: Record<string, string> = {
|
||||||
|
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<Record<string, typeof learnings>>((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 (
|
||||||
|
<DashboardLayout>
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="p-2 bg-indigo-100 rounded-lg">
|
||||||
|
<Brain className="w-6 h-6 text-indigo-600" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold">Apprentissages du système</h1>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Corrections manuelles mémorisées et appliquées automatiquement aux prochains imports
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{totalCount > 0 && (
|
||||||
|
<AlertDialog>
|
||||||
|
<AlertDialogTrigger asChild>
|
||||||
|
<Button variant="outline" className="text-red-600 border-red-200 hover:bg-red-50">
|
||||||
|
<Trash2 className="w-4 h-4 mr-2" />
|
||||||
|
Tout supprimer
|
||||||
|
</Button>
|
||||||
|
</AlertDialogTrigger>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>Supprimer tous les apprentissages ?</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
Cette action supprimera les {totalCount} apprentissage(s) mémorisé(s). Le système ne pourra plus
|
||||||
|
appliquer automatiquement ces corrections lors des prochains imports.
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>Annuler</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
className="bg-red-600 hover:bg-red-700"
|
||||||
|
onClick={() => deleteAllMutation.mutate()}
|
||||||
|
>
|
||||||
|
Supprimer tout
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Stats */}
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<Card>
|
||||||
|
<CardContent className="pt-6">
|
||||||
|
<div className="text-3xl font-bold text-indigo-600">{supplierCount}</div>
|
||||||
|
<div className="text-sm text-muted-foreground mt-1">Fournisseur(s) appris</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardContent className="pt-6">
|
||||||
|
<div className="text-3xl font-bold text-indigo-600">{totalCount}</div>
|
||||||
|
<div className="text-sm text-muted-foreground mt-1">Correction(s) mémorisée(s)</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Comment ça fonctionne */}
|
||||||
|
<Card className="border-indigo-200 bg-indigo-50/50">
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<CardTitle className="text-base flex items-center gap-2">
|
||||||
|
<Brain className="w-4 h-4 text-indigo-600" />
|
||||||
|
Comment fonctionne l'apprentissage ?
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="text-sm text-muted-foreground space-y-1">
|
||||||
|
<p>
|
||||||
|
Quand vous modifiez manuellement le <strong>type d'achat</strong>, le <strong>service concerné</strong> ou
|
||||||
|
la <strong>ventilation comptable</strong> d'une facture dans sa page de détail, le système mémorise cette
|
||||||
|
correction pour ce fournisseur.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
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.
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Liste des apprentissages */}
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="flex items-center justify-center h-32 text-muted-foreground">
|
||||||
|
<RefreshCw className="w-5 h-5 animate-spin mr-2" />
|
||||||
|
Chargement...
|
||||||
|
</div>
|
||||||
|
) : totalCount === 0 ? (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="flex flex-col items-center justify-center py-16 text-center">
|
||||||
|
<Brain className="w-12 h-12 text-gray-300 mb-4" />
|
||||||
|
<h3 className="text-lg font-semibold text-gray-500">Aucun apprentissage enregistré</h3>
|
||||||
|
<p className="text-sm text-muted-foreground mt-2 max-w-sm">
|
||||||
|
Modifiez manuellement les champs d'une facture (type d'achat, service, ventilation) pour que le système
|
||||||
|
commence à apprendre.
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{Object.entries(grouped).map(([supplierKey, entries]) => (
|
||||||
|
<Card key={supplierKey}>
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<CardTitle className="text-base capitalize">{supplierKey}</CardTitle>
|
||||||
|
<Badge variant="outline" className="text-xs">
|
||||||
|
{entries?.length} correction(s)
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{entries?.map((learning) => (
|
||||||
|
<div
|
||||||
|
key={learning.id}
|
||||||
|
className="flex items-center justify-between p-3 rounded-lg border bg-muted/30"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3 flex-1 min-w-0">
|
||||||
|
<Badge className={`text-xs shrink-0 ${FIELD_COLORS[learning.fieldName] || "bg-gray-100 text-gray-800"}`}>
|
||||||
|
{FIELD_LABELS[learning.fieldName] || learning.fieldName}
|
||||||
|
</Badge>
|
||||||
|
<div className="flex items-center gap-2 text-sm min-w-0">
|
||||||
|
{learning.originalValue && (
|
||||||
|
<>
|
||||||
|
<span className="text-muted-foreground line-through truncate">
|
||||||
|
{learning.originalValue}
|
||||||
|
</span>
|
||||||
|
<span className="text-muted-foreground">→</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<span className="font-medium text-foreground truncate">
|
||||||
|
{learning.correctedValue || <em className="text-muted-foreground">vide</em>}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3 shrink-0 ml-3">
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
Appliqué {learning.applyCount}×
|
||||||
|
</span>
|
||||||
|
<AlertDialog>
|
||||||
|
<AlertDialogTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-7 w-7 text-red-400 hover:text-red-600 hover:bg-red-50"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-3.5 h-3.5" />
|
||||||
|
</Button>
|
||||||
|
</AlertDialogTrigger>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>Supprimer cet apprentissage ?</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
La correction « {FIELD_LABELS[learning.fieldName] || learning.fieldName} → {learning.correctedValue} »
|
||||||
|
pour le fournisseur « {supplierKey} » ne sera plus appliquée automatiquement.
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>Annuler</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
className="bg-red-600 hover:bg-red-700"
|
||||||
|
onClick={() => deleteMutation.mutate({ id: learning.id })}
|
||||||
|
>
|
||||||
|
Supprimer
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</DashboardLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
12
drizzle/0018_warm_changeling.sql
Normal file
12
drizzle/0018_warm_changeling.sql
Normal file
@@ -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`)
|
||||||
|
);
|
||||||
1694
drizzle/meta/0018_snapshot.json
Normal file
1694
drizzle/meta/0018_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -127,6 +127,13 @@
|
|||||||
"when": 1775987314108,
|
"when": 1775987314108,
|
||||||
"tag": "0017_clammy_toad",
|
"tag": "0017_clammy_toad",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 18,
|
||||||
|
"version": "5",
|
||||||
|
"when": 1776019747088,
|
||||||
|
"tag": "0018_warm_changeling",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -372,3 +372,28 @@ export const bapHistory = mysqlTable("bapHistory", {
|
|||||||
});
|
});
|
||||||
export type BapHistory = typeof bapHistory.$inferSelect;
|
export type BapHistory = typeof bapHistory.$inferSelect;
|
||||||
export type InsertBapHistory = typeof bapHistory.$inferInsert;
|
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;
|
||||||
|
|||||||
82
server/db.ts
82
server/db.ts
@@ -41,7 +41,10 @@ import {
|
|||||||
ServiceSignature,
|
ServiceSignature,
|
||||||
bapHistory,
|
bapHistory,
|
||||||
InsertBapHistory,
|
InsertBapHistory,
|
||||||
BapHistory
|
BapHistory,
|
||||||
|
invoiceLearnings,
|
||||||
|
InsertInvoiceLearning,
|
||||||
|
InvoiceLearning
|
||||||
} from "../drizzle/schema";
|
} from "../drizzle/schema";
|
||||||
import { ENV } from './_core/env';
|
import { ENV } from './_core/env';
|
||||||
|
|
||||||
@@ -811,3 +814,80 @@ export async function deleteBapHistoryEntry(id: number): Promise<void> {
|
|||||||
if (!db) return;
|
if (!db) return;
|
||||||
await db.delete(bapHistory).where(eq(bapHistory.id, id));
|
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<InvoiceLearning[]> {
|
||||||
|
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<InvoiceLearning[]> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
const db = await getDb();
|
||||||
|
if (!db) return;
|
||||||
|
await db.delete(invoiceLearnings).where(eq(invoiceLearnings.userId, userId));
|
||||||
|
}
|
||||||
|
|||||||
@@ -66,6 +66,11 @@ import {
|
|||||||
createBapHistoryEntry,
|
createBapHistoryEntry,
|
||||||
getBapHistoryByUser,
|
getBapHistoryByUser,
|
||||||
deleteBapHistoryEntry,
|
deleteBapHistoryEntry,
|
||||||
|
getLearningsByUser,
|
||||||
|
getLearningsBySupplier,
|
||||||
|
upsertLearning,
|
||||||
|
deleteLearning,
|
||||||
|
deleteAllLearnings,
|
||||||
} from "./db";
|
} from "./db";
|
||||||
import { loginLocal, hashPassword, getAzureAuthUrl, isAzureAdConfigured, generateToken } from "./auth";
|
import { loginLocal, hashPassword, getAzureAuthUrl, isAzureAdConfigured, generateToken } from "./auth";
|
||||||
import { extractInvoicesWithMistral, generateMetadataJSON } from "./invoiceExtractor";
|
import { extractInvoicesWithMistral, generateMetadataJSON } from "./invoiceExtractor";
|
||||||
@@ -288,6 +293,28 @@ export const appRouter = router({
|
|||||||
// Don't fail the import if automation fails
|
// 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<string, string> = {};
|
||||||
|
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++;
|
importedCount++;
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
errorsCount++;
|
errorsCount++;
|
||||||
@@ -870,89 +897,23 @@ export const appRouter = router({
|
|||||||
invoiceIds: z.array(z.number()).min(1),
|
invoiceIds: z.array(z.number()).min(1),
|
||||||
}))
|
}))
|
||||||
.mutation(async ({ input, ctx }) => {
|
.mutation(async ({ input, ctx }) => {
|
||||||
|
// Relance UNIQUEMENT les automatismes (sans re-extraction LLM)
|
||||||
const { applyAutomationRules } = await import("./automationEngine");
|
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 allInvoices = await getInvoicesByUser(ctx.user.id);
|
||||||
const selected = allInvoices.filter(inv => input.invoiceIds.includes(inv.id));
|
const selected = allInvoices.filter(inv => input.invoiceIds.includes(inv.id));
|
||||||
if (selected.length === 0) throw new TRPCError({ code: 'NOT_FOUND', message: 'Aucune facture trouvée' });
|
if (selected.length === 0) throw new TRPCError({ code: 'NOT_FOUND', message: 'Aucune facture trouvée' });
|
||||||
|
|
||||||
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 processed = 0;
|
||||||
let errors = 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) {
|
for (const invoice of selected) {
|
||||||
try {
|
try {
|
||||||
// Lire le PDF source
|
const automationUpdates = await applyAutomationRules(ctx.user.id, invoice);
|
||||||
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());
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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) {
|
if (Object.keys(automationUpdates).length > 0) {
|
||||||
await updateInvoice(invoice.id, automationUpdates);
|
await updateInvoice(invoice.id, automationUpdates);
|
||||||
}
|
}
|
||||||
}
|
results.push({ id: invoice.id, success: true });
|
||||||
|
|
||||||
results.push({ id: invoice.id, success: true, qualityScore: extracted.qualityScore });
|
|
||||||
processed++;
|
processed++;
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
console.error(`[Reprocess] Error on invoice ${invoice.id}:`, err);
|
console.error(`[Reprocess] Error on invoice ${invoice.id}:`, err);
|
||||||
@@ -1949,5 +1910,50 @@ export const appRouter = router({
|
|||||||
return { success: true };
|
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;
|
export type AppRouter = typeof appRouter;
|
||||||
|
|||||||
Reference in New Issue
Block a user