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>Service</TableHead>
<TableHead>Type achat</TableHead> <TableHead>Type achat</TableHead>
<TableHead>Ventilation</TableHead> <TableHead>Ventilation</TableHead>
<TableHead>Abonnement</TableHead>
<TableHead>Score</TableHead> <TableHead>Score</TableHead>
<TableHead>Statut</TableHead> <TableHead>Statut</TableHead>
<TableHead className="w-32">Actions</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> <option value="__ADD_NEW__" className="font-semibold text-blue-600"> Ajouter...</option>
</select> </select>
</TableCell> </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>{getQualityBadge(invoice.qualityScore)}</TableCell>
<TableCell>{getExportStatusBadge(invoice.exportStatus || "not_exported")}</TableCell> <TableCell>{getExportStatusBadge(invoice.exportStatus || "not_exported")}</TableCell>
<TableCell> <TableCell>

View File

@@ -20,6 +20,7 @@ export default function Settings() {
const [orderNumberKeywords, setOrderNumberKeywords] = useState(""); const [orderNumberKeywords, setOrderNumberKeywords] = useState("");
const [supplierKeywords, setSupplierKeywords] = useState(""); const [supplierKeywords, setSupplierKeywords] = useState("");
const [totalAmountKeywords, setTotalAmountKeywords] = useState(""); const [totalAmountKeywords, setTotalAmountKeywords] = useState("");
const [subscriptionKeywords, setSubscriptionKeywords] = useState("");
const [sftpHost, setSftpHost] = useState(""); const [sftpHost, setSftpHost] = useState("");
const [sftpPort, setSftpPort] = useState(22); const [sftpPort, setSftpPort] = useState(22);
const [sftpUsername, setSftpUsername] = useState(""); const [sftpUsername, setSftpUsername] = useState("");
@@ -36,6 +37,7 @@ export default function Settings() {
setOrderNumberKeywords(settings.orderNumberKeywords || ""); setOrderNumberKeywords(settings.orderNumberKeywords || "");
setSupplierKeywords(settings.supplierKeywords || ""); setSupplierKeywords(settings.supplierKeywords || "");
setTotalAmountKeywords(settings.totalAmountKeywords || ""); setTotalAmountKeywords(settings.totalAmountKeywords || "");
setSubscriptionKeywords(settings.subscriptionKeywords || "");
setSftpHost(settings.sftpHost || ""); setSftpHost(settings.sftpHost || "");
setSftpPort(settings.sftpPort || 22); setSftpPort(settings.sftpPort || 22);
setSftpUsername(settings.sftpUsername || ""); setSftpUsername(settings.sftpUsername || "");
@@ -77,6 +79,7 @@ export default function Settings() {
orderNumberKeywords, orderNumberKeywords,
supplierKeywords, supplierKeywords,
totalAmountKeywords, totalAmountKeywords,
subscriptionKeywords,
sftpHost, sftpHost,
sftpPort, sftpPort,
sftpUsername, sftpUsername,
@@ -205,6 +208,20 @@ export default function Settings() {
rows={2} rows={2}
/> />
</div> </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> </CardContent>
</Card> </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, "when": 1770819973462,
"tag": "0005_small_roxanne_simpson", "tag": "0005_small_roxanne_simpson",
"breakpoints": true "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 // Extracted text from PDF
extractedText: text("extractedText"), // Full text extracted from the invoice 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 // SFTP Export tracking
exportedAt: timestamp("exportedAt"), exportedAt: timestamp("exportedAt"),
exportMode: mysqlEnum("exportMode", ["manual", "automatic"]), 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" orderNumberKeywords: text("orderNumberKeywords"), // Ex: "Cde client, Référence commande, PO Number"
supplierKeywords: text("supplierKeywords"), // Ex: "Vendeur, Société, Émetteur" supplierKeywords: text("supplierKeywords"), // Ex: "Vendeur, Société, Émetteur"
totalAmountKeywords: text("totalAmountKeywords"), // Ex: "Net à payer, Total à régler, Amount due" totalAmountKeywords: text("totalAmountKeywords"), // Ex: "Net à payer, Total à régler, Amount due"
subscriptionKeywords: text("subscriptionKeywords"), // Ex: "Abonnement, Subscription, Mensuel, Annuel"
// SFTP Configuration // SFTP Configuration
sftpHost: varchar("sftpHost", { length: 255 }), sftpHost: varchar("sftpHost", { length: 255 }),
sftpPort: int("sftpPort").default(22), sftpPort: int("sftpPort").default(22),

View File

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

View File

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

View File

@@ -12,6 +12,7 @@ export interface ExtractedInvoiceData {
pageRange: string; pageRange: string;
qualityScore: number; // 0-100 qualityScore: number; // 0-100
extractedText: string | null; // Full text extracted from the invoice PDF extractedText: string | null; // Full text extracted from the invoice PDF
isSubscription: boolean; // True if subscription keywords detected
} }
export interface MultiInvoiceResult { export interface MultiInvoiceResult {
@@ -102,6 +103,7 @@ export async function extractInvoicesWithMistral(
orderNumber?: string | null; orderNumber?: string | null;
supplier?: string | null; supplier?: string | null;
totalAmount?: string | null; totalAmount?: string | null;
subscription?: string | null;
} }
): Promise<MultiInvoiceResult> { ): Promise<MultiInvoiceResult> {
const startTime = Date.now(); 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.orderNumber) hints.push(`Numéro de commande: ${customKeywords.orderNumber}`);
if (customKeywords.supplier) hints.push(`Fournisseur: ${customKeywords.supplier}`); if (customKeywords.supplier) hints.push(`Fournisseur: ${customKeywords.supplier}`);
if (customKeywords.totalAmount) hints.push(`Montant total: ${customKeywords.totalAmount}`); if (customKeywords.totalAmount) hints.push(`Montant total: ${customKeywords.totalAmount}`);
if (customKeywords.subscription) hints.push(`Abonnement: ${customKeywords.subscription}`);
if (hints.length > 0) { if (hints.length > 0) {
keywordsHint = `\n\nMots-clés personnalisés à rechercher:\n${hints.join("\n")}`; 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) - totalAmount: Montant total TTC (nombre décimal)
- pageRange: Plage de pages de cette facture (ex: "1-2" ou "5") - 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) - 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: 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, "totalAmount": 123.45,
"pageRange": "1-2", "pageRange": "1-2",
"qualityScore": 85, "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, orderNumber: settings.orderNumberKeywords,
supplier: settings.supplierKeywords, supplier: settings.supplierKeywords,
totalAmount: settings.totalAmountKeywords, totalAmount: settings.totalAmountKeywords,
subscription: settings.subscriptionKeywords,
} : undefined; } : undefined;
const model = settings?.llmModel || "mistral-large-latest"; const model = settings?.llmModel || "mistral-large-latest";
@@ -235,6 +236,7 @@ export const appRouter = router({
pageRange: invoiceData.pageRange, pageRange: invoiceData.pageRange,
qualityScore: invoiceData.qualityScore, qualityScore: invoiceData.qualityScore,
extractedText: invoiceData.extractedText, extractedText: invoiceData.extractedText,
isSubscription: invoiceData.isSubscription ? 1 : 0,
metadataFileKey: metadataKey, metadataFileKey: metadataKey,
metadataFileUrl: metadataUrl, metadataFileUrl: metadataUrl,
status: "completed", status: "completed",
@@ -308,6 +310,7 @@ export const appRouter = router({
serviceConcerne: z.string().optional(), serviceConcerne: z.string().optional(),
typeAchat: z.enum(["CAPEX", "OPEX"]).optional(), typeAchat: z.enum(["CAPEX", "OPEX"]).optional(),
ventilationComptable: z.string().optional(), ventilationComptable: z.string().optional(),
isSubscription: z.number().min(0).max(1).optional(),
}), }),
})) }))
.mutation(async ({ input, ctx }) => { .mutation(async ({ input, ctx }) => {
@@ -374,6 +377,7 @@ export const appRouter = router({
orderNumberKeywords: z.string().optional(), orderNumberKeywords: z.string().optional(),
supplierKeywords: z.string().optional(), supplierKeywords: z.string().optional(),
totalAmountKeywords: z.string().optional(), totalAmountKeywords: z.string().optional(),
subscriptionKeywords: z.string().optional(),
sftpHost: z.string().optional(), sftpHost: z.string().optional(),
sftpPort: z.number().optional(), sftpPort: z.number().optional(),
sftpUsername: z.string().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] 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 - [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 - [ ] 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