diff --git a/client/src/pages/InvoiceDetail.tsx b/client/src/pages/InvoiceDetail.tsx index bdda05c..2b0daaf 100644 --- a/client/src/pages/InvoiceDetail.tsx +++ b/client/src/pages/InvoiceDetail.tsx @@ -72,19 +72,6 @@ export default function InvoiceDetail() { }); const handleSave = () => { - // Calculate new quality score based on filled fields - let filledFields = 0; - let totalFields = 6; - - if (formData.supplierName) filledFields++; - if (formData.invoiceNumber) filledFields++; - if (formData.invoiceDate) filledFields++; - if (formData.deliveryNoteNumber) filledFields++; - if (formData.orderNumber) filledFields++; - if (formData.totalAmount) filledFields++; - - const newQualityScore = Math.round((filledFields / totalFields) * 100); - // Apprentissage : enregistrer les corrections manuelles sur les champs clés // On compare les valeurs actuelles (invoice) avec les nouvelles valeurs (formData) if (invoice && formData.supplierName) { diff --git a/server/invoiceQuality.test.ts b/server/invoiceQuality.test.ts new file mode 100644 index 0000000..d2c4ad7 --- /dev/null +++ b/server/invoiceQuality.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it } from "vitest"; +import { calculateInvoiceQualityScore } from "@shared/invoiceQuality"; + +const defaultRequiredFields = [ + { fieldName: "supplierName", isRequired: 1 }, + { fieldName: "invoiceNumber", isRequired: 1 }, + { fieldName: "invoiceDate", isRequired: 1 }, + { fieldName: "totalAmount", isRequired: 1 }, + { fieldName: "deliveryNoteNumber", isRequired: 0 }, +]; + +describe("calculateInvoiceQualityScore", () => { + it("passe à 100 % lorsque tous les champs obligatoires sont complétés manuellement", () => { + expect(calculateInvoiceQualityScore({ + supplierName: "Microsoft France", + invoiceNumber: "FAC-2026-001", + invoiceDate: new Date("2026-09-30"), + totalAmount: "0.00", + }, defaultRequiredFields)).toBe(100); + }); + + it("recalcule proportionnellement lorsque seuls certains champs obligatoires sont remplis", () => { + expect(calculateInvoiceQualityScore({ + supplierName: "Microsoft France", + invoiceNumber: "FAC-2026-001", + invoiceDate: new Date("2026-09-30"), + totalAmount: " ", + }, defaultRequiredFields)).toBe(75); + }); + + it("utilise la configuration personnalisée des champs obligatoires", () => { + const configuration = [ + { fieldName: "recipientName", isRequired: 1 }, + { fieldName: "supplierName", isRequired: 0 }, + ]; + + expect(calculateInvoiceQualityScore({ + recipientName: "ITINOVA", + supplierName: "", + }, configuration)).toBe(100); + }); + + it("utilise les champs obligatoires par défaut lorsqu’aucune configuration n’existe encore", () => { + expect(calculateInvoiceQualityScore({ + supplierName: "Fournisseur", + invoiceNumber: "F-1", + invoiceDate: new Date("2026-09-30"), + totalAmount: "120.00", + }, [])).toBe(100); + }); + + it("retourne 100 % lorsque le compte a explicitement rendu tous ses champs optionnels", () => { + expect(calculateInvoiceQualityScore({}, [ + { fieldName: "supplierName", isRequired: 0 }, + ])).toBe(100); + }); +}); diff --git a/server/routers.ts b/server/routers.ts index ddd7dc0..497e551 100644 --- a/server/routers.ts +++ b/server/routers.ts @@ -1,6 +1,7 @@ import { z } from "zod"; import { COOKIE_NAME } from "@shared/const"; import { BAP_MIN_QUALITY_SCORE, meetsBapQualityThreshold } from "@shared/bapEligibility"; +import { calculateInvoiceQualityScore } from "@shared/invoiceQuality"; interface Condition { field: string; @@ -476,13 +477,23 @@ export const appRouter = router({ if (!invoice || (ctx.user.role !== 'admin' && invoice.userId !== ctx.user.id)) { throw new TRPCError({ code: "NOT_FOUND" }); } + + // Le score reflète toujours les valeurs actuellement enregistrées, y compris + // celles corrigées manuellement. La configuration est celle du propriétaire + // de la facture afin de conserver la même règle que lors de son extraction. + const fieldsConfiguration = await getLlmFieldsConfigByUser(invoice.userId); + const qualityScore = calculateInvoiceQualityScore( + { ...invoice, ...input.data }, + fieldsConfiguration, + ); await updateInvoice(input.id, { ...input.data, + qualityScore, manuallyEdited: 1, }); - return { success: true }; + return { success: true, qualityScore }; }), delete: protectedProcedure diff --git a/shared/invoiceQuality.ts b/shared/invoiceQuality.ts new file mode 100644 index 0000000..c8bc53c --- /dev/null +++ b/shared/invoiceQuality.ts @@ -0,0 +1,42 @@ +/** Champ requis configuré pour le calcul du score de qualité d’une facture. */ +export type QualityFieldConfig = { + fieldName: string; + isRequired: number; +}; + +/** Référentiel utilisé tant qu’un compte ne possède pas encore sa configuration en base. */ +export const DEFAULT_REQUIRED_INVOICE_FIELDS = [ + "supplierName", + "invoiceNumber", + "invoiceDate", + "totalAmount", +] as const; + +function hasValue(value: unknown): boolean { + if (value === null || value === undefined) return false; + if (typeof value === "string") return value.trim().length > 0; + if (value instanceof Date) return Number.isFinite(value.getTime()); + return true; +} + +/** + * Recalcule le score selon les champs obligatoires configurés pour le compte. + * Les champs optionnels n'affectent jamais le résultat. Le calcul est purement + * déterministe afin d'être identique après import et après une correction manuelle. + */ +export function calculateInvoiceQualityScore( + invoice: Record, + fieldsConfiguration: readonly QualityFieldConfig[], +): number { + const configuredRequiredFields = fieldsConfiguration + .filter((field) => field.isRequired === 1) + .map((field) => field.fieldName); + const requiredFields = fieldsConfiguration.length > 0 + ? configuredRequiredFields + : DEFAULT_REQUIRED_INVOICE_FIELDS; + + if (requiredFields.length === 0) return 100; + + const completedFields = requiredFields.filter((fieldName) => hasValue(invoice[fieldName])).length; + return Math.round((completedFields / requiredFields.length) * 100); +} diff --git a/todo.md b/todo.md index 95251be..7c9ffc7 100644 --- a/todo.md +++ b/todo.md @@ -5,6 +5,11 @@ - [x] Corriger la cause sans lancer d’import ni créer de planification non sollicitée, avec sélection explicite du compte source pour les administrateurs - [x] Valider les contrôles, les tests et le comportement de démarrage/arrêt +## Diagnostic démarrage OAuth2 après test IMAP concluant — 24/09/2026 +- [x] Reproduire le démarrage OAuth2 sans importer de facture et tracer le résultat API : la réception automatique est désactivée en production pour le compte configuré +- [ ] Corriger la cause du message générique de démarrage +- [ ] Vérifier le démarrage et l’arrêt contrôlés avec les tests de non-régression + ## Phase 1: Base de données et schéma - [x] Créer table users avec support authentification locale + Azure AD - [x] Créer table sourceFiles pour fichiers PDF uploadés