Checkpoint: Ajout du champ Abonnement (OUI/NON) aux factures avec détection automatique par mots-clés et édition directe dans la liste

This commit is contained in:
Manus
2026-02-11 09:54:17 -05:00
parent 14c50fc8fd
commit 4535cd6bdf
13 changed files with 2277 additions and 2 deletions

View File

@@ -409,6 +409,7 @@ export default function Invoices() {
<TableHead>Service</TableHead>
<TableHead>Type achat</TableHead>
<TableHead>Ventilation</TableHead>
<TableHead>Abonnement</TableHead>
<TableHead>Score</TableHead>
<TableHead>Statut</TableHead>
<TableHead className="w-32">Actions</TableHead>
@@ -509,6 +510,21 @@ export default function Invoices() {
<option value="__ADD_NEW__" className="font-semibold text-blue-600"> Ajouter...</option>
</select>
</TableCell>
<TableCell className="text-sm">
<select
value={invoice.isSubscription ? "OUI" : "NON"}
onChange={(e) => {
updateFieldMutation.mutate({
id: invoice.id,
data: { isSubscription: e.target.value === "OUI" ? 1 : 0 },
});
}}
className="w-full px-2 py-1 border rounded text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<option value="NON">NON</option>
<option value="OUI">OUI</option>
</select>
</TableCell>
<TableCell>{getQualityBadge(invoice.qualityScore)}</TableCell>
<TableCell>{getExportStatusBadge(invoice.exportStatus || "not_exported")}</TableCell>
<TableCell>

View File

@@ -20,6 +20,7 @@ export default function Settings() {
const [orderNumberKeywords, setOrderNumberKeywords] = useState("");
const [supplierKeywords, setSupplierKeywords] = useState("");
const [totalAmountKeywords, setTotalAmountKeywords] = useState("");
const [subscriptionKeywords, setSubscriptionKeywords] = useState("");
const [sftpHost, setSftpHost] = useState("");
const [sftpPort, setSftpPort] = useState(22);
const [sftpUsername, setSftpUsername] = useState("");
@@ -36,6 +37,7 @@ export default function Settings() {
setOrderNumberKeywords(settings.orderNumberKeywords || "");
setSupplierKeywords(settings.supplierKeywords || "");
setTotalAmountKeywords(settings.totalAmountKeywords || "");
setSubscriptionKeywords(settings.subscriptionKeywords || "");
setSftpHost(settings.sftpHost || "");
setSftpPort(settings.sftpPort || 22);
setSftpUsername(settings.sftpUsername || "");
@@ -77,6 +79,7 @@ export default function Settings() {
orderNumberKeywords,
supplierKeywords,
totalAmountKeywords,
subscriptionKeywords,
sftpHost,
sftpPort,
sftpUsername,
@@ -205,6 +208,20 @@ export default function Settings() {
rows={2}
/>
</div>
<div className="space-y-2">
<Label htmlFor="subscriptionKeywords">Abonnement</Label>
<Textarea
id="subscriptionKeywords"
value={subscriptionKeywords}
onChange={(e) => setSubscriptionKeywords(e.target.value)}
placeholder="Abonnement, Subscription, Mensuel, Annuel, Recurring"
rows={2}
/>
<p className="text-sm text-muted-foreground">
Les factures contenant ces mots-clés seront automatiquement marquées comme abonnement
</p>
</div>
</CardContent>
</Card>

View File

@@ -0,0 +1 @@
ALTER TABLE `invoices` ADD `isSubscription` int DEFAULT 0 NOT NULL;

View File

@@ -0,0 +1 @@
ALTER TABLE `userSettings` ADD `subscriptionKeywords` text;

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -43,6 +43,20 @@
"when": 1770819973462,
"tag": "0005_small_roxanne_simpson",
"breakpoints": true
},
{
"idx": 6,
"version": "5",
"when": 1770821197778,
"tag": "0006_fresh_bromley",
"breakpoints": true
},
{
"idx": 7,
"version": "5",
"when": 1770821233743,
"tag": "0007_faulty_titanium_man",
"breakpoints": true
}
]
}

View File

@@ -92,6 +92,9 @@ export const invoices = mysqlTable("invoices", {
// Extracted text from PDF
extractedText: text("extractedText"), // Full text extracted from the invoice PDF
// Subscription flag
isSubscription: int("isSubscription").default(0).notNull(), // 0 = NON, 1 = OUI
// SFTP Export tracking
exportedAt: timestamp("exportedAt"),
exportMode: mysqlEnum("exportMode", ["manual", "automatic"]),
@@ -122,6 +125,7 @@ export const userSettings = mysqlTable("userSettings", {
orderNumberKeywords: text("orderNumberKeywords"), // Ex: "Cde client, Référence commande, PO Number"
supplierKeywords: text("supplierKeywords"), // Ex: "Vendeur, Société, Émetteur"
totalAmountKeywords: text("totalAmountKeywords"), // Ex: "Net à payer, Total à régler, Amount due"
subscriptionKeywords: text("subscriptionKeywords"), // Ex: "Abonnement, Subscription, Mensuel, Annuel"
// SFTP Configuration
sftpHost: varchar("sftpHost", { length: 255 }),
sftpPort: int("sftpPort").default(22),

View File

@@ -74,6 +74,7 @@ async function processEmailAttachment(
orderNumber: settings.orderNumberKeywords,
supplier: settings.supplierKeywords,
totalAmount: settings.totalAmountKeywords,
subscription: settings.subscriptionKeywords,
} : undefined;
const model = settings?.llmModel || "mistral-large-latest";

View File

@@ -75,6 +75,7 @@ async function processFolderFile(
orderNumber: settings.orderNumberKeywords,
supplier: settings.supplierKeywords,
totalAmount: settings.totalAmountKeywords,
subscription: settings.subscriptionKeywords,
} : undefined;
const model = settings?.llmModel || "mistral-large-latest";

View File

@@ -12,6 +12,7 @@ export interface ExtractedInvoiceData {
pageRange: string;
qualityScore: number; // 0-100
extractedText: string | null; // Full text extracted from the invoice PDF
isSubscription: boolean; // True if subscription keywords detected
}
export interface MultiInvoiceResult {
@@ -102,6 +103,7 @@ export async function extractInvoicesWithMistral(
orderNumber?: string | null;
supplier?: string | null;
totalAmount?: string | null;
subscription?: string | null;
}
): Promise<MultiInvoiceResult> {
const startTime = Date.now();
@@ -127,6 +129,7 @@ export async function extractInvoicesWithMistral(
if (customKeywords.orderNumber) hints.push(`Numéro de commande: ${customKeywords.orderNumber}`);
if (customKeywords.supplier) hints.push(`Fournisseur: ${customKeywords.supplier}`);
if (customKeywords.totalAmount) hints.push(`Montant total: ${customKeywords.totalAmount}`);
if (customKeywords.subscription) hints.push(`Abonnement: ${customKeywords.subscription}`);
if (hints.length > 0) {
keywordsHint = `\n\nMots-clés personnalisés à rechercher:\n${hints.join("\n")}`;
@@ -145,7 +148,8 @@ Pour chaque facture trouvée, extrais les informations suivantes:
- totalAmount: Montant total TTC (nombre décimal)
- pageRange: Plage de pages de cette facture (ex: "1-2" ou "5")
- qualityScore: Score de qualité de l'extraction de 0 à 100 (100 = toutes les informations trouvées et claires)
- extractedText: Texte complet extrait de la facture (tout le texte visible sur les pages de cette facture)${keywordsHint}
- extractedText: Texte complet extrait de la facture (tout le texte visible sur les pages de cette facture)
- isSubscription: true si la facture contient des mots-clés liés à un abonnement (abonnement, subscription, mensuel, annuel, recurring, etc.), false sinon${keywordsHint}
Réponds UNIQUEMENT avec un objet JSON valide au format suivant:
{
@@ -161,7 +165,8 @@ Réponds UNIQUEMENT avec un objet JSON valide au format suivant:
"totalAmount": 123.45,
"pageRange": "1-2",
"qualityScore": 85,
"extractedText": "Texte complet de la facture..."
"extractedText": "Texte complet de la facture...",
"isSubscription": false
}
]
}

View File

@@ -157,6 +157,7 @@ export const appRouter = router({
orderNumber: settings.orderNumberKeywords,
supplier: settings.supplierKeywords,
totalAmount: settings.totalAmountKeywords,
subscription: settings.subscriptionKeywords,
} : undefined;
const model = settings?.llmModel || "mistral-large-latest";
@@ -235,6 +236,7 @@ export const appRouter = router({
pageRange: invoiceData.pageRange,
qualityScore: invoiceData.qualityScore,
extractedText: invoiceData.extractedText,
isSubscription: invoiceData.isSubscription ? 1 : 0,
metadataFileKey: metadataKey,
metadataFileUrl: metadataUrl,
status: "completed",
@@ -308,6 +310,7 @@ export const appRouter = router({
serviceConcerne: z.string().optional(),
typeAchat: z.enum(["CAPEX", "OPEX"]).optional(),
ventilationComptable: z.string().optional(),
isSubscription: z.number().min(0).max(1).optional(),
}),
}))
.mutation(async ({ input, ctx }) => {
@@ -374,6 +377,7 @@ export const appRouter = router({
orderNumberKeywords: z.string().optional(),
supplierKeywords: z.string().optional(),
totalAmountKeywords: z.string().optional(),
subscriptionKeywords: z.string().optional(),
sftpHost: z.string().optional(),
sftpPort: z.number().optional(),
sftpUsername: z.string().optional(),

12
todo.md
View File

@@ -247,3 +247,15 @@
- [x] Ajouter un bouton "Détail" (icône FileText) dans la liste des factures
- [x] Créer une boîte de dialogue pour afficher le texte complet
- [ ] Tester l'affichage du texte extrait avec une nouvelle facture importée
## Champ Abonnement pour les factures
- [x] Ajouter colonne isSubscription (OUI/NON) à la table invoices avec valeur par défaut NON
- [x] Ajouter champ subscriptionKeywords dans la table userSettings
- [x] Ajouter interface pour gérer les mots-clés d'abonnement dans la page Settings
- [x] Modifier l'extraction pour détecter automatiquement les abonnements via mots-clés
- [x] Modifier le prompt Mistral pour demander isSubscription
- [x] Modifier routers.ts, emailImportService.ts et folderImportService.ts pour utiliser subscriptionKeywords
- [x] Ajouter la colonne Abonnement dans la liste des factures
- [x] Permettre l'édition directe du champ Abonnement dans la liste (select OUI/NON)
- [x] Ajouter isSubscription dans le schéma de validation update
- [ ] Tester la détection automatique avec une nouvelle facture importée