99 Commits

Author SHA1 Message Date
Santinova Deployment
5f6c61d2f0 fix: retirer les alias URL historiques
Some checks failed
Validation applicative / TypeScript, tests et build (push) Failing after 2m16s
2026-10-25 00:47:39 +00:00
Santinova Deployment
fd970db7fb feat: normaliser les URL recette par entité
Some checks failed
Validation applicative / TypeScript, tests et build (push) Failing after 2m55s
2026-10-24 17:06:48 +00:00
Manus
16759b4ad5 feat: purge import history by retention 2026-10-09 10:22:46 +00:00
Manus
6d72547509 fix: paginate import history and report 2026-10-09 09:58:08 +00:00
Manus
939dddd1d8 fix: require BAP before original PDF exports 2026-10-09 08:44:02 +00:00
Manus
e402233120 fix: transmettre le moteur Référentiel IA 2026-10-09 07:30:44 +00:00
Manus
3b0be885e2 fix: compacter extraction pour Santino rapide 2026-10-02 21:36:27 +00:00
Manus
f9434d3a79 fix: autoriser le reimport apres suppression 2026-10-02 21:15:28 +00:00
Manus
1617267128 fix: expliciter le résultat des imports manuels 2026-10-02 21:10:42 +00:00
Manus
ba4e42c7f1 fix: recalculer les scores de qualité à l import 2026-10-02 20:59:23 +00:00
Manus
70f867d62b fix: relancer les sources vides après purge 2026-10-02 20:46:21 +00:00
Manus
b09dccb078 fix: accepter les réponses IA avec contrôles OCR 2026-10-02 20:05:45 +00:00
Manus
c5f0c9eed6 fix: rétablir la suppression de facture en recette 2026-10-02 17:05:14 +00:00
Manus
aebdafd026 Checkpoint: Paramètres > Intelligence IA affiche désormais automatiquement le moteur, et le modèle quand il est fourni, décidés par le Référentiel IA dès sa sélection. La consultation ne transmet aucune facture ; un état de chargement et une reprise sur erreur sont affichés. Test Référentiel IA, TypeScript et build validés. 2026-10-02 15:13:56 +00:00
Manus
32b07370d8 Checkpoint: Correction Référentiel IA déployée en recette : une variable de jeton vide ne masque plus le secret Docker monté. La mutation exacte du bouton Paramètres > Intelligence IA est validée avec une session de contrôle : connexion réussie, application Démat. Facturation DSI et moteur Manus Forge. Conteneur healthy, routes HTTP 200 et volume PDF (301 fichiers) conservés. 2026-10-02 15:07:38 +00:00
Manus
edb4726d5f Checkpoint: Correction recette : une variable REFERENTIEL_IA_TOKEN vide masquait le secret Docker monté. Le serveur privilégie désormais un jeton explicite non vide et bascule vers le fichier secret sinon. Test de non-régression ajouté ; 89 tests, TypeScript et build validés. 2026-10-02 15:01:42 +00:00
Manus
eba192e159 docs: record controlled referential ia invocation 2026-09-30 16:38:06 +00:00
Manus
11c346153c fix: preserve recorded connector migration 2026-09-30 16:37:11 +00:00
Manus
136797ca36 Merge remote-tracking branch 'user_github/main'
# Conflicts:
#	server/webImportSourcesMigration.test.ts
#	todo.md
2026-09-30 16:35:50 +00:00
Manus
84cb2ac805 docs: record recipe referential ia activation 2026-09-30 16:34:40 +00:00
Manus
81a470cf60 fix: default recipe referential gateway url 2026-09-30 16:27:20 +00:00
Manus
5eb8c8b3ac fix: mount referentiel ia token as docker secret 2026-09-30 16:25:53 +00:00
Manus
e9d25d9270 feat: route invoice analysis through referentiel ia 2026-09-30 16:20:22 +00:00
Manus
584e9631e5 feat: lancer manuellement les imports SFR 2026-09-30 16:46:05 +02:00
Manus
0d65592d75 feat: lancer manuellement les imports SFR 2026-09-30 16:42:27 +02:00
Manus
e08df31909 Checkpoint: Ajout du déclenchement manuel pour les connecteurs web SFR : confirmation explicite, automate Chromium côté serveur, récupération des PDF par session authentifiée et injection dans le flux existant de stockage/dédoublonnage/analyse. Le bouton est désactivé pour les types sans moteur réellement livré. Le résultat affiche factures importées et doublons, sans créer de planification. Chromium et playwright-core sont intégrés à l’image. Validation locale : 77 tests, TypeScript et build. 2026-09-30 14:39:43 +00:00
Manus
405649c606 Checkpoint: Création de connecteur web : l’URL est normalisée, la table est présente sur les trois environnements et le flux HTTPS de production a été validé avec le même profil utilisateur (création puis suppression sans résidu). Toute erreur de persistance est désormais remplacée côté navigateur par un message générique ; les logs ne conservent que le code technique MySQL/Drizzle, jamais la requête ni ses paramètres. Recette et production déployées, santé, MySQL, routes HTTP 200 et volumes PDF vérifiés. Suite validée : 75 tests, TypeScript et build. 2026-09-30 14:28:48 +00:00
Manus
d2937fd26a fix: journaliser le code SQL sans paramètre 2026-09-30 16:03:54 +02:00
Manus
a9c81b4238 fix: masquer les erreurs SQL des connecteurs web 2026-09-30 15:57:09 +02:00
Manus
b294c11a52 Checkpoint: Les erreurs de création de connecteur sont désormais neutralisées côté API : aucune requête SQL ni aucun paramètre de connexion ne peut être renvoyé au navigateur. Un test dédié le garantit. Le flux public HTTPS de production a été validé pour le même profil utilisateur : création, normalisation https et suppression réussies, sans ligne résiduelle. 74 tests, TypeScript et build validés. 2026-09-30 13:55:08 +00:00
Manus
343ad71a59 fix: rendre la migration des connecteurs idempotente 2026-09-30 15:27:05 +02:00
Manus
137c1f0652 Checkpoint: La migration de création de webImportSources est rendue idempotente avec CREATE TABLE IF NOT EXISTS. Un test garantit la présence des colonnes de connecteur et l’absence de modification de la table factures. 73 tests, TypeScript et build validés ; aucune donnée métier n’a été modifiée. 2026-09-30 13:26:07 +00:00
Manus
77d57f4e42 fix: normaliser les URLs des connecteurs web 2026-09-30 14:55:13 +02:00
Manus
26eec3831a Checkpoint: Les connecteurs web acceptent désormais les noms de domaine saisis sans protocole : HTTPS est ajouté automatiquement dans l’interface et à l’API. Les URL HTTP(S) valides restent acceptées et les adresses incorrectes affichent un message métier clair. Sept tests dédiés, la suite complète (71 tests), TypeScript et build ont été validés. 2026-09-30 12:52:53 +00:00
Manus
daa5fb8463 fix: recalculer le score après correction manuelle 2026-09-30 12:20:12 +02:00
Manus
082e6d8326 Checkpoint: Le score de qualité est désormais recalculé et persisté par le serveur après chaque modification manuelle de facture. Il s’appuie sur les champs obligatoires configurés pour le propriétaire de la facture, conserve un référentiel par défaut en l’absence de configuration et accepte explicitement un compte ayant rendu tous ses champs optionnels. Le calcul local non persisté de la page détail est supprimé. 64 tests, TypeScript et build validés. 2026-09-30 10:16:27 +00:00
Manus
5aa2b9e285 Checkpoint: Réception automatique e-mail sécurisée : le planificateur est créé uniquement après un test IMAP concluant, les erreurs d’authentification sont affichées explicitement et ne déclenchent plus de boucle. Les administrateurs peuvent sélectionner, démarrer, arrêter et suivre le compte source déjà configuré sans afficher ni recopier de secrets. Tests de démarrage réussi/refusé, arrêt et ciblage de compte ajoutés ; 59 tests, TypeScript et build validés. 2026-09-24 09:34:22 +00:00
Manus Deployment
d85c975fb1 fix: prioriser le réseau interne de facturation
All checks were successful
Validation applicative / TypeScript, tests et build (push) Successful in 1m56s
2026-09-09 08:41:24 +00:00
Manus Deployment
8158a6b9af chore: exécuter les migrations versionnées en recette
All checks were successful
Validation applicative / TypeScript, tests et build (push) Successful in 1m55s
2026-09-09 08:31:54 +00:00
Manus Deployment
7754affe96 fix: tracer le moteur IA réellement invoqué
All checks were successful
Validation applicative / TypeScript, tests et build (push) Successful in 1m52s
2026-09-09 08:27:43 +00:00
Manus
fcde121438 Checkpoint: La version f7cfa73 est déployée en recette et production. Les règles de destination sont résolues facture par facture pour les exportations d’abonnements, avec repli des paramètres globaux. Les deux conteneurs sont sains, les routes abonnements répondent HTTP 200, les volumes PDF sont montés et le dashboard de production voit l’application active. 2026-09-04 10:57:39 +00:00
Manus
f7cfa73206 Checkpoint: Les exportations PDF manuelles résolvent désormais une destination facture par facture via les règles actives d’Automatismes d’export, y compris pour les factures d’abonnement. Les paramètres globaux demeurent le repli ; les destinations Teams et SharePoint utilisent l’envoi Microsoft existant. Suite complète : 56 tests, TypeScript et build validés.
All checks were successful
Validation applicative / TypeScript, tests et build (push) Successful in 3m6s
2026-09-04 10:44:41 +00:00
Manus
1b7a46e05f Checkpoint: Le garde-fou de stockage distingue désormais les environnements : les serveurs Santinova exigent explicitement REQUIRE_STORAGE_VOLUME=true et bloquent sans volume PDF monté, tandis que l’hébergement cloud crée le répertoire local nécessaire sans arrêter le démarrage. Les tests, TypeScript, build et une sonde de démarrage cloud sont validés. 2026-09-03 15:35:55 +00:00
Manus
2c5b285e54 Checkpoint: Les deux environnements utilisent désormais le commit a13fe40 : le volume PDF historique est externe, monté avec nocopy, contrôlé au démarrage et inclus dans le healthcheck. Les volumes contiennent et servent toujours 300 fichiers en recette et 147 en production ; les deux services répondent HTTP 200. 2026-09-03 09:27:03 +00:00
Manus
a13fe40eb4 Checkpoint: Le stockage PDF devient un volume Docker externe explicitement nommé, avec nocopy. Le conteneur refuse de démarrer si /app/storage n’est pas un montage inscriptible, et son healthcheck contrôle aussi ce montage. Les tests, TypeScript et build ont été validés.
All checks were successful
Validation applicative / TypeScript, tests et build (push) Successful in 3m20s
2026-09-03 09:20:33 +00:00
Manus
eb2c9cc6b6 Checkpoint: Le volume persistant demat_facturation_storage est de nouveau monté sur /app/storage en production et les deux comptes Microsoft ADM.pareige et Olivier Pareige sont administrateurs, avec accès aux 146 factures globales. Les contrôles de conteneur, stockage et réponse PDF publique sont validés. 2026-09-03 07:36:04 +00:00
Manus
bd48ef52d2 Checkpoint: Le volume demat_facturation_storage est monté sur /app/storage, répertoire réellement servi par Express et utilisé pour les PDF source. Test de non-régression ajouté au compose. Les tests, TypeScript et build sont validés avant l’application ciblée en production. 2026-09-02 15:52:23 +00:00
Manus
53edecf4e7 Checkpoint: Incident 404 production corrigé : le domaine Traefik est désormais configurable via APP_DOMAIN, configuré avec le domaine production sur le serveur. Le service applicatif est sain et les routes publiques racine et Automatismes répondent HTTP 200. La base et les volumes n’ont pas été touchés. 2026-09-02 15:21:44 +00:00
Manus
b5f2cb48fe Checkpoint: Le routage Traefik utilise désormais APP_DOMAIN avec le domaine recette comme repli, ce qui évite qu’une image de production serve un routeur vers la recette. Test unitaire de configuration ajouté ; 53 tests, TypeScript et build validés. 2026-09-02 15:19:28 +00:00
Manus
fe0961ab39 Checkpoint: Version f7c522b1 déployée en recette et production : conteneurs sains, routes Automatismes et Paramètres en HTTP 200, migrations importTrigger et exportAutomationRules appliquées, dashboard production vérifié. Les deux factures SFR importées avant la création de leur règle ont été mises à jour avec OPEX, DSI SANTINOVA, ventilation SANTINOVA et abonnement Oui après confirmation. 2026-09-02 14:11:02 +00:00
Manus
f7c522b18e Checkpoint: Les onglets Automatismes d’import et Automatismes d’export sont visuellement différenciés en bleu et violet. Le libellé « Mode d’export BAP » devient « Mode d’export » et le texte de repli ne mentionne plus BAP. Tests, TypeScript et rendu visuel validés en sandbox.
All checks were successful
Validation applicative / TypeScript, tests et build (push) Successful in 2m15s
2026-09-02 12:42:46 +00:00
Manus
071376c9b1 Checkpoint: La configuration Microsoft Azure AD est de nouveau centralisée dans Paramètres : URL SharePoint, Tenant ID, Client ID, secret masqué, date d’expiration et tests de connexion/upload. Les Automatismes d’export ne contiennent plus d’identifiants et restent dédiés aux règles de destination. Tests, TypeScript, build et rendu de la page Paramètres validés en sandbox. 2026-09-02 12:31:50 +00:00
Manus
582ca77f28 Checkpoint: Automatismes séparé en onglets Import et Export. Les règles d’export ciblent un destinataire ou une ventilation, avec destination locale, Teams ou SharePoint et ouverture navigateur optionnelle. La validation BAP unique ou en masse applique la première règle active par priorité puis conserve le paramétrage historique comme repli. Paramètres d’export déplacés vers Automatismes et page renommée Paramètres. Migration additive, tests, TypeScript, build et contrôles visuels validés. 2026-09-02 12:13:23 +00:00
Manus
da19629ee0 Checkpoint: Factures BAP inclut désormais, après Année et Mois, les filtres Destinataires, Entités et Ventilations comptables de la page Factures. Leur logique est identique, avec valeurs absentes et règle SANTINOVA/ITINOVA fondée sur DSI SANTINOVA. Les filtres sont conservés au retour depuis le détail. Tests, TypeScript, build et rendu visuel validés en sandbox. 2026-09-02 11:52:20 +00:00
Manus
0b2f1b781d Checkpoint: Historique des imports enrichi avec le compte, la source, le déclencheur, les résultats, les erreurs et les informations du fichier source. Les administrateurs peuvent déclencher une lecture e-mail ponctuelle pour une boîte configurée, avec confirmation explicite, sans activer ni créer de planification. Migration importTrigger appliquée en sandbox ; TypeScript, 48 tests, build et rendus bureau/mobile validés. 2026-09-02 11:43:23 +00:00
Manus
f53bb82256 Checkpoint: Investigation des deux factures créées le 1er septembre 2026 en production : les deux enregistrements sont rattachés à des sources d’import e-mail du compte utilisateur 2. La planification de ce compte était encore activée avec une cadence de 30 minutes ; elle a depuis été désactivée avec les autres comptes, et le service applicatif a été recréé sans toucher aux données métier. 2026-09-02 10:46:37 +00:00
Manus
4ad6cb2705 Checkpoint: Import e-mail automatique désactivé pour tous les comptes en recette et en production. Seuls les conteneurs applicatifs ont été recréés pour supprimer les planifications en mémoire. Contrôles confirmés : zéro paramètre e-mail activé, aucun redémarrage du service e-mail dans les journaux, conteneurs sains et HTTP 200 sur les deux environnements. 2026-09-01 14:19:19 +00:00
Manus
d692e228de Checkpoint: Budget réel trié par montant total décroissant et graphique annuel BAP versus abonnements déployés et vérifiés en recette puis production. Les routes /dashboard et /real-budget répondent HTTP 200, le conteneur applicatif est sain, et le bundle graphique est actif. Le dashboard et le portail de production référencent déjà l’application en ligne ; aucune modification n’était nécessaire. 2026-08-30 09:27:14 +00:00
Manus
9997e383cc Checkpoint: Budget réel confirmé en ordre décroissant de montant total ; graphique en barres annuel comparant les montants BAP et abonnements ajouté au tableau de bord.
All checks were successful
Validation applicative / TypeScript, tests et build (push) Successful in 3m19s
2026-08-30 08:56:27 +00:00
Manus
5128322a6d Checkpoint: Ajout du récapitulatif annuel BAP et abonnements au tableau de bord, et de Budget réel par fournisseur avec filtres Année/Mois et ventilation abonnements/hors abonnement. 2026-08-29 22:15:35 +00:00
Manus
43fc8d523c Checkpoint: Page Factures abonnements ajoutée après Factures BAP ; filtres Année/Mois et filtres de la page Factures disponibles sur les deux listes ; déploiements recette et production vérifiés. 2026-08-29 18:52:49 +00:00
Manus
cd73dd3d58 Checkpoint: Préparation du déploiement production de la page Factures abonnements et de ses filtres Année/Mois après validation de la recette. 2026-08-29 18:45:38 +00:00
Manus
28a00cc771 chore: préparer le déploiement recette des factures abonnements
Some checks failed
Validation applicative / TypeScript, tests et build (push) Failing after 2m58s
2026-08-29 18:33:00 +00:00
Manus
e2d067ff4b Checkpoint: Ajout de la page Factures abonnements, accessible après Factures BAP, partageant les recherches, filtres et actions de la page Factures. Filtres Année et Mois ajoutés aux deux listes, avec règle date de facture puis réception. 2026-08-29 18:32:12 +00:00
Manus
0b01ebe331 Checkpoint: Seuil BAP unifié à 90 % dans le serveur et l’interface, déployé en recette et production. Les deux environnements répondent HTTP 200, les conteneurs sont actifs et les bundles BAP correspondent au correctif. 2026-08-27 14:29:52 +00:00
Manus
a9a2a4b312 Checkpoint: Seuil BAP à 90 % validé en sandbox et recette. Le manifeste est rétabli vers le dépôt Gitea de production avant promotion ; le suivi du rétablissement recette est mis à jour. 2026-08-27 14:22:06 +00:00
Manus
deb5b5735c chore: préparer le déploiement recette du seuil BAP à 90
Some checks failed
Validation applicative / TypeScript, tests et build (push) Failing after 54m40s
2026-08-27 13:20:18 +00:00
Manus
a0c440dfae Checkpoint: Le seuil d’éligibilité et de validation BAP est centralisé à 90 % dans le serveur et l’interface. Les validations unitaires, TypeScript, build et rendu de la page Factures BAP sont réussis en sandbox. 2026-08-27 13:02:07 +00:00
Manus
a88b342a0d Checkpoint: Filtre par type d’action des automatismes déployé en recette puis production. Les catégories Type d’achat, Service, Ventilation, Abonnement, Abonnement Oui et Abonnement Non sont disponibles. Contrôles HTTP, conteneurs, commit et bundle réussis sur les deux environnements. 2026-08-27 10:16:24 +00:00
Manus
7e1253615b chore: suivre le déploiement du filtre par action 2026-08-27 10:09:52 +00:00
Manus
1a7d5cb64e chore: suivre le déploiement du filtre d actions
All checks were successful
Validation applicative / TypeScript, tests et build (push) Successful in 9m49s
2026-08-27 09:46:34 +00:00
Manus
0536035303 Checkpoint: Ajout d’un filtre par action dans la liste des automatismes, avec catégories Type d’achat, Service, Ventilation, Abonnement, Abonnement Oui et Abonnement Non. Tests, TypeScript, build et rendu visuel validés en sandbox. 2026-08-27 07:02:36 +00:00
Manus
d7677df41b Checkpoint: Correctif Abonnement visible dans les automatismes déployé en recette puis production. Production validée : commit d9c1130, conteneur sain, HTTP 200 et bundle AutomationRules vérifié. 2026-08-26 12:51:08 +00:00
Manus
d9c1130359 Checkpoint: Préparation du déploiement production du correctif de visibilité Abonnement : manifeste restauré vers le dépôt Gitea production et tâches de vérification tracées. 2026-08-26 12:41:50 +00:00
Manus
401659ec99 Checkpoint: Action Abonnement rendue immédiatement visible dans le bloc Actions des automatismes. Rebuild recette finalisé après arrêt autorisé du processus non applicatif saturant le serveur ; HTTP 200 et bundle validé. 2026-08-26 12:11:40 +00:00
Manus
3528fc6905 Checkpoint: L’option Abonnement est remontée en tête des actions du dialogue d’édition, dans un encadré explicite avec Oui, Non et Ne pas modifier. Tests, TypeScript et build validés.
Some checks failed
Validation applicative / TypeScript, tests et build (push) Failing after 3s
2026-08-26 11:49:09 +00:00
Manus
8b47a78e4a Checkpoint: Retour depuis le détail vers Factures BAP avec conservation des filtres et du tri ; automatismes enrichis avec Abonnement Oui/Non/Ne pas modifier. Tests, TypeScript et build validés ; recette HTTP 200 et conteneurs sains. 2026-08-26 10:35:50 +00:00
Manus
a281212bab chore: cible le dépôt Gitea recette
All checks were successful
Validation applicative / TypeScript, tests et build (push) Successful in 3m6s
2026-08-26 10:21:24 +00:00
Manus
b603541adf Checkpoint: Correctifs validés en sandbox : retour contextuel depuis le détail vers Factures BAP avec filtres et tri conservés, et nouvelle action Abonnement Oui/Non/Ne pas modifier dans les automatismes. Ajout de tests de non-régression. 2026-08-26 10:20:20 +00:00
Manus
1b3e3cdd67 Checkpoint: Migration ImapFlow déployée et validée sur recette et production. HTTP et conteneurs sains, OAuth2 production réussi, premier cycle email connecté sans message non lu, image Docker allégée et Vite exclu du runtime. 2026-08-22 19:12:43 +00:00
Manus
e4328ab652 Checkpoint: Préparation du déploiement production après validation recette : manifeste Gitea production restauré, image runtime allégée, Vite exclu du bundle serveur et diagnostic OAuth2 ImapFlow basé sur la configuration active ajouté. 2026-08-22 19:03:59 +00:00
Manus
692fbbe912 Checkpoint: Correction du runtime Docker : Vite et sa configuration sont maintenant chargés uniquement en développement via imports indirects. Le bundle serveur de production ne référence plus Vite ni ses plugins ; 31 tests, TypeScript et build validés.
Some checks failed
Validation applicative / TypeScript, tests et build (push) Failing after 3m22s
2026-08-22 18:22:46 +00:00
Manus
ece737e11d Checkpoint: Dockerfile final optimisé : les dépendances de développement restent dans le builder et seules les dépendances runtime sont installées dans l’image finale. Cette stratégie remplace le prune et la copie du node_modules complet qui bloquaient l’export sur le serveur de recette.
All checks were successful
Validation applicative / TypeScript, tests et build (push) Successful in 2m49s
2026-08-22 17:47:07 +00:00
Manus
75e7a9256c Checkpoint: Migration ImapFlow validée, historique recette fusionné sans réintroduire l’ancien client IMAP, et image Docker optimisée avec suppression des dépendances de développement après build pour réduire le runtime et fiabiliser l’export sur le serveur de recette.
Some checks failed
Validation applicative / TypeScript, tests et build (push) Failing after 38m5s
2026-08-22 17:03:36 +00:00
Manus
52b5e49082 Merge remote-tracking branch 'recette/server-state'
All checks were successful
Validation applicative / TypeScript, tests et build (push) Successful in 10m21s
# Conflicts:
#	app.json
#	server/emailImportService.ts
2026-08-22 16:09:35 +00:00
Manus
d27703f878 Merge remote-tracking branch 'recette/main'
All checks were successful
Validation applicative / TypeScript, tests et build (push) Successful in 8m30s
# Conflicts:
#	app.json
#	server/emailImportService.ts
#	todo.md
2026-08-22 15:57:13 +00:00
Manus
098d707289 Checkpoint: Remplacement complet de imap 0.8 par ImapFlow : OAuth2 moderne avec jeton brut, TLS strict, traitement séquentiel des messages non lus, verrou anti-concurrence conservé, marquage Seen uniquement après succès, test de connexion adapté, diagnostic OAuth2 et tests unitaires ajoutés. Validation : 31 tests, TypeScript, build et authentification réelle Microsoft 365 réussis. 2026-08-22 15:51:06 +00:00
Manus
66c4940aba Checkpoint: Version finale après fusion du dépôt Gitea de production et nettoyage contrôlé : empreinte SHA-256 unique des PDF, import IMAP séquencé et non concurrent, protections étendues à toutes les sources, migration validée et suivi de production clôturé. 2026-08-22 10:55:15 +00:00
Manus
147dd6e5a0 Merge remote-tracking branch 'prod-dsi/main'
# Conflicts:
#	server/emailImportService.ts
2026-08-22 10:39:08 +00:00
Manus
b7525ab51e Merge remote-tracking branch 'prod/main' 2026-08-22 10:33:05 +00:00
Manus
d729a94a96 Checkpoint: Correction robuste des doublons de factures : empreinte SHA-256 unique des PDF sources, détection globale avant import manuel/email/dossier/web, verrou contre les vérifications IMAP concurrentes, attente réelle de fin de traitement avant marquage lu, migration et tests de non-régression. 2026-08-22 10:31:54 +00:00
Manus Admin
957494f8ef ci: trigger 2026-08-21 13:48:09 +02:00
Manus CI
269823fd78 CI fix : corrections pour CI production 2026-08-21 11:47:55 +00:00
Manus CI
295bb26378 CI : workflow validate.yml, manifeste ci.required:true, garde VITEST, tests isolés 2026-08-21 11:15:58 +00:00
Manus Admin
de797c1c0b fix: empêcher la duplication massive de fichiers lors de l import email
- markSeen: true pour marquer les emails immédiatement
- Vérification doublon avant stockage (findSourceFileByFileName)
- Verrou anti-concurrence par utilisateur
2026-08-18 15:05:31 +02:00
Manus Admin
de76a761a3 fix: empêcher la duplication massive de fichiers lors de l'import email
- Marquer les emails comme lus immédiatement (markSeen: true)
- Vérifier si le fichier existe déjà avant de le stocker (findSourceFileByFileName)
- Ajouter un verrou anti-concurrence par utilisateur (runningChecks)
- Empêche la création de ~570x doublons par facture
2026-08-18 14:50:31 +02:00
Manus
8759d85f3d Merge remote-tracking branch 'recette/main' 2026-08-18 07:42:06 +00:00
Manus
89c65ca979 chore: versionner le manifeste de déploiement 2026-08-18 09:40:27 +02:00
Manus
eecbd07b5c chore: versionner le manifeste de déploiement 2026-08-17 22:35:40 +02:00
99 changed files with 17841 additions and 1144 deletions

View File

@@ -29,7 +29,8 @@ RUN pnpm install --frozen-lockfile
# Copy source code
COPY . .
# Build frontend + backend
# Build frontend + backend. Les dépendances de développement restent confinées
# au builder et ne sont jamais copiées dans l'image d'exécution.
RUN pnpm build
# ============================================================
@@ -42,26 +43,32 @@ RUN apt-get update && apt-get install -y \
poppler-utils \
libvips42 \
curl \
chromium \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
# Copy node_modules from builder (already compiled, including sharp native binaries)
COPY --from=builder /app/node_modules ./node_modules
ENV NODE_ENV=production
# Installer uniquement les dépendances d'exécution réduit fortement la taille
# de la couche exportée. Les binaires Sharp sont fournis par ses paquets
# optionnels de plateforme et ne nécessitent pas de script post-installation.
RUN npm install -g pnpm@10.4.1
COPY package.json pnpm-lock.yaml ./
COPY patches/ ./patches/
RUN pnpm install --prod --frozen-lockfile --ignore-scripts
# Copy built assets from builder (vite outputs to dist/public, esbuild to dist/)
COPY --from=builder /app/dist ./dist
# Copy package.json (needed for module resolution)
COPY package.json ./
# Copy drizzle migrations
COPY drizzle/ ./drizzle/
COPY drizzle.config.ts ./
COPY scripts/ensure-storage-volume.sh ./scripts/ensure-storage-volume.sh
# Create uploads directory
RUN mkdir -p /app/uploads
# Create uploads directory and make the PDF volume guard executable.
RUN mkdir -p /app/uploads && chmod 755 /app/scripts/ensure-storage-volume.sh
EXPOSE 3000
CMD ["node", "dist/index.js"]
CMD ["/app/scripts/ensure-storage-volume.sh"]

View File

@@ -3,12 +3,12 @@
"name": "Démat. Facturation DSI",
"category": "SANTINOVA",
"urls": {
"recette": "https://demat-facturation.recette.santinova-soft.org",
"prod": "https://demat-facturation.santinova-soft.org"
"recette": "https://demat-facturation-santinova.recette.santinova-soft.org",
"prod": "https://demat-facturation-santinova.santinova-soft.org"
},
"containerName": "demat-facturation-app",
"image": "images/demat-facturation-dsi.jpg",
"giteaRepo": "demat-facturation",
"giteaRepo": "demat-facturation-dsi",
"giteaOwner": "manus-admin",
"ci": {
"required": true

View File

@@ -26,6 +26,15 @@ const ImportReport = lazy(() => import("./pages/ImportReport"));
const LearningSettings = lazy(() => import("./pages/LearningSettings"));
const VentilationFreePro = lazy(() => import("./pages/VentilationFreePro"));
const WebImportSources = lazy(() => import("./pages/WebImportSources"));
const RealBudget = lazy(() => import("./pages/RealBudget"));
function SubscriptionInvoices() {
return <Invoices scope="subscriptions" />;
}
function AllInvoices() {
return <Invoices />;
}
function RouteFallback() {
return <div className="min-h-screen bg-background" aria-busy="true" aria-label="Chargement" />;
@@ -38,8 +47,10 @@ function Router() {
<Route path="/login" component={Login} />
<Route path="/dashboard" component={Dashboard} />
<Route path="/upload" component={Upload} />
<Route path="/invoices" component={Invoices} />
<Route path="/invoices" component={AllInvoices} />
<Route path="/invoices-bap" component={InvoicesBAP} />
<Route path="/invoices-subscriptions" component={SubscriptionInvoices} />
<Route path="/real-budget" component={RealBudget} />
<Route path="/invoices/:id" component={InvoiceDetail} />
<Route path="/settings" component={Settings} />
<Route path="/import-settings" component={ImportSettings} />

View File

@@ -0,0 +1,101 @@
import { useEffect, useMemo, useState } from "react";
import { Calendar, CheckCircle2, Cloud, Loader2, Save, Upload, Wifi } from "lucide-react";
import { trpc } from "@/lib/trpc";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { toast } from "sonner";
/** Les identifiants Azure restent centralisés dans Paramètres, hors des règles métier. */
export default function AzureAdExportSettingsCard() {
const utils = trpc.useUtils();
const { data: settings } = trpc.importSettings.get.useQuery();
const [sharepointUrl, setSharepointUrl] = useState("");
const [tenantId, setTenantId] = useState("");
const [clientId, setClientId] = useState("");
const [clientSecret, setClientSecret] = useState("");
const [secretExpiresAt, setSecretExpiresAt] = useState("");
useEffect(() => {
if (!settings) return;
setSharepointUrl(settings.exportFolder || "");
setTenantId(settings.azureTenantId || "");
setClientId(settings.azureClientId || "");
setSecretExpiresAt(settings.azureSecretExpiresAt ? new Date(settings.azureSecretExpiresAt).toISOString().slice(0, 10) : "");
}, [settings]);
const updateMutation = trpc.importSettings.update.useMutation({
onSuccess: () => {
toast.success("Configuration Microsoft Azure AD enregistrée");
setClientSecret("");
utils.importSettings.get.invalidate();
},
onError: (error) => toast.error(error.message || "Impossible d’enregistrer la configuration Azure AD"),
});
const testConnectionMutation = trpc.importSettings.testAzureConnection.useMutation();
const testUploadMutation = trpc.importSettings.testSharePointUpload.useMutation();
const expiration = useMemo(() => {
if (!secretExpiresAt) return null;
const date = new Date(`${secretExpiresAt}T12:00:00Z`);
if (Number.isNaN(date.getTime())) return null;
return Math.ceil((date.getTime() - Date.now()) / 86_400_000);
}, [secretExpiresAt]);
const save = () => {
if (!settings) return;
if (!sharepointUrl.trim() || !tenantId.trim() || !clientId.trim()) {
toast.error("URL SharePoint, Tenant ID et Client ID sont obligatoires");
return;
}
updateMutation.mutate({
manualImportEnabled: settings.manualImportEnabled,
autoImportEnabled: settings.autoImportEnabled,
emailImportEnabled: settings.emailImportEnabled,
exportFolder: sharepointUrl.trim(),
exportFolderType: "sharepoint",
bapExportMode: settings.bapExportMode,
azureTenantId: tenantId.trim(),
azureClientId: clientId.trim(),
azureSecretExpiresAt: secretExpiresAt || null,
...(clientSecret ? { azureClientSecret: clientSecret } : {}),
});
};
const testConnection = async () => {
try {
const result = await testConnectionMutation.mutateAsync();
result.success ? toast.success(result.message || "Connexion Azure AD réussie") : toast.error(result.error || "Connexion Azure AD impossible");
} catch (error: any) {
toast.error(error.message || "Connexion Azure AD impossible");
}
};
const testUpload = async () => {
try {
const result = await testUploadMutation.mutateAsync();
if (!result.success) {
toast.error(result.error || "Upload SharePoint impossible");
return;
}
toast.success("Fichier test déposé dans SharePoint");
if (result.webUrl) window.open(result.webUrl, "_blank", "noopener,noreferrer");
} catch (error: any) {
toast.error(error.message || "Upload SharePoint impossible");
}
};
const canTest = Boolean(sharepointUrl && tenantId && clientId);
return <Card className="border-2 border-cyan-200">
<CardHeader className="border-b bg-gradient-to-r from-cyan-50 to-sky-50"><div className="flex items-center gap-3"><div className="rounded-lg bg-cyan-600 p-2"><Cloud className="h-6 w-6 text-white" /></div><div><CardTitle>Configuration Microsoft Azure AD</CardTitle><CardDescription>Accès partagé utilisé par les destinations SharePoint et Teams des automatismes d’export.</CardDescription></div></div></CardHeader>
<CardContent className="space-y-4 pt-6">
<div className="space-y-2"><Label htmlFor="sharepoint-url">URL SharePoint / chemin</Label><Input id="sharepoint-url" value={sharepointUrl} onChange={(event) => setSharepointUrl(event.target.value)} placeholder="https://…sharepoint.com/:f:/r/sites/…/Documents/Factures" /><p className="text-xs text-muted-foreground">Pour un canal Teams, utilisez l’URL SharePoint du dossier Fichiers concerné.</p></div>
<div className="grid gap-4 md:grid-cols-2"><div className="space-y-2"><Label htmlFor="azure-tenant-id">ID de l’annuaire (Tenant ID)</Label><Input id="azure-tenant-id" value={tenantId} onChange={(event) => setTenantId(event.target.value)} /></div><div className="space-y-2"><Label htmlFor="azure-client-id">ID d’application (Client ID)</Label><Input id="azure-client-id" value={clientId} onChange={(event) => setClientId(event.target.value)} /></div></div>
<div className="grid gap-4 md:grid-cols-2"><div className="space-y-2"><Label htmlFor="azure-client-secret">Secret client</Label><Input id="azure-client-secret" type="password" value={clientSecret} onChange={(event) => setClientSecret(event.target.value)} placeholder="Conserver le secret configuré" /><p className="text-xs text-muted-foreground">Laissez vide pour conserver le secret enregistré. Permissions requises : Files.ReadWrite.All et Sites.ReadWrite.All.</p></div><div className="space-y-2"><Label htmlFor="azure-secret-expiration">Date d’expiration du secret</Label><Input id="azure-secret-expiration" type="date" value={secretExpiresAt} onChange={(event) => setSecretExpiresAt(event.target.value)} />{expiration !== null && <p className={expiration <= 30 ? "text-xs text-amber-700" : "text-xs text-emerald-700"}><Calendar className="mr-1 inline h-3 w-3" />{expiration < 0 ? "Secret expiré" : `Secret valide encore ${expiration} jours`}</p>}</div></div>
<div className="flex flex-wrap gap-2 border-t pt-4"><Button onClick={save} disabled={updateMutation.isPending}><Save className="mr-2 h-4 w-4" />{updateMutation.isPending ? "Enregistrement…" : "Enregistrer Azure AD"}</Button><Button variant="outline" onClick={testConnection} disabled={testConnectionMutation.isPending || !canTest}>{testConnectionMutation.isPending ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <Wifi className="mr-2 h-4 w-4" />}Tester la connexion Azure AD</Button><Button variant="outline" onClick={testUpload} disabled={testUploadMutation.isPending || !canTest}>{testUploadMutation.isPending ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <Upload className="mr-2 h-4 w-4" />}Tester l’upload SharePoint</Button></div>
{settings?.azureClientSecret && <p className="flex items-center gap-1 text-xs text-emerald-700"><CheckCircle2 className="h-3.5 w-3.5" />Un secret client est déjà enregistré de manière masquée.</p>}
</CardContent>
</Card>;
}

View File

@@ -53,6 +53,8 @@ const menuStructure: MenuItem[] = [
{ icon: Upload, label: "Import", path: "/upload" },
{ icon: FileText, label: "Factures", path: "/invoices" },
{ icon: FileText, label: "Factures BAP", path: "/invoices-bap" },
{ icon: FileText, label: "Factures abonnements", path: "/invoices-subscriptions" },
{ icon: BarChart2, label: "Budget réel", path: "/real-budget" },
],
},
{
@@ -69,7 +71,7 @@ const menuStructure: MenuItem[] = [
color: "from-orange-500 to-amber-500",
children: [
{ icon: Settings, label: "Paramètres IA et Signatures", path: "/settings" },
{ icon: Download, label: "Paramètres import / export", path: "/import-settings" },
{ icon: Download, label: "Paramètres", 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" },

View File

@@ -0,0 +1,177 @@
import { useEffect, useMemo, useState } from "react";
import { FolderOpen, Monitor, Pencil, Plus, Power, PowerOff, Save, Trash2 } from "lucide-react";
import { trpc } from "@/lib/trpc";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { toast } from "sonner";
type ConditionField = "recipientName" | "ventilationComptable";
type DestinationType = "local" | "teams" | "sharepoint";
const LABELS: Record<ConditionField, string> = {
recipientName: "Destinataire",
ventilationComptable: "Ventilation",
};
const DESTINATION_LABELS: Record<DestinationType, string> = {
local: "Dossier local",
teams: "Teams",
sharepoint: "SharePoint",
};
export default function ExportAutomationRulesPanel() {
const utils = trpc.useUtils();
const { data: rules = [], isLoading } = trpc.exportAutomationRules.list.useQuery();
const { data: invoices = [] } = trpc.invoices.list.useQuery();
const { data: importSettings } = trpc.importSettings.get.useQuery();
const [editingId, setEditingId] = useState<number | null>(null);
const [name, setName] = useState("");
const [conditionField, setConditionField] = useState<ConditionField>("recipientName");
const [conditionValue, setConditionValue] = useState("");
const [destinationType, setDestinationType] = useState<DestinationType>("local");
const [destinationPath, setDestinationPath] = useState("");
const [openInBrowser, setOpenInBrowser] = useState(false);
const [defaultExportMode, setDefaultExportMode] = useState<"browser" | "folder" | "both">("browser");
const [defaultDestinationType, setDefaultDestinationType] = useState<DestinationType>("local");
const [defaultDestinationPath, setDefaultDestinationPath] = useState("");
useEffect(() => {
if (!importSettings) return;
setDefaultExportMode(importSettings.bapExportMode || "browser");
setDefaultDestinationType((importSettings.exportFolderType || "local") as DestinationType);
setDefaultDestinationPath(importSettings.exportFolder || "");
}, [importSettings]);
const conditionValues = useMemo(() => {
const field = conditionField === "recipientName" ? "recipientName" : "ventilationComptable";
return Array.from(new Set(invoices.map((invoice: any) => invoice[field]).filter(Boolean))).sort();
}, [conditionField, invoices]);
const resetForm = () => {
setEditingId(null);
setName("");
setConditionField("recipientName");
setConditionValue("");
setDestinationType("local");
setDestinationPath("");
setOpenInBrowser(false);
};
const createMutation = trpc.exportAutomationRules.create.useMutation({
onSuccess: () => {
toast.success("Règle d’export créée");
utils.exportAutomationRules.list.invalidate();
resetForm();
},
onError: (error) => toast.error(error.message || "Impossible de créer la règle"),
});
const updateMutation = trpc.exportAutomationRules.update.useMutation({
onSuccess: () => {
toast.success("Règle d’export mise à jour");
utils.exportAutomationRules.list.invalidate();
resetForm();
},
onError: (error) => toast.error(error.message || "Impossible de mettre à jour la règle"),
});
const deleteMutation = trpc.exportAutomationRules.delete.useMutation({
onSuccess: () => {
toast.success("Règle d’export supprimée");
utils.exportAutomationRules.list.invalidate();
},
onError: (error) => toast.error(error.message || "Impossible de supprimer la règle"),
});
const updateDefaultExportMutation = trpc.importSettings.update.useMutation({
onSuccess: () => {
toast.success("Paramètres d’export enregistrés");
utils.importSettings.get.invalidate();
},
onError: (error) => toast.error(error.message || "Impossible d’enregistrer les paramètres d’export"),
});
const saveRule = () => {
if (!name.trim() || !conditionValue.trim() || !destinationPath.trim()) {
toast.error("Nom, valeur de condition et destination sont obligatoires");
return;
}
const payload = {
name: name.trim(),
conditionField,
conditionValue: conditionValue.trim(),
destinationType,
destinationPath: destinationPath.trim(),
openInBrowser: openInBrowser ? 1 : 0,
};
if (editingId) updateMutation.mutate({ id: editingId, ...payload });
else createMutation.mutate({ ...payload, isActive: 1, priority: rules.length });
};
const editRule = (rule: any) => {
setEditingId(rule.id);
setName(rule.name);
setConditionField(rule.conditionField);
setConditionValue(rule.conditionValue);
setDestinationType(rule.destinationType);
setDestinationPath(rule.destinationPath);
setOpenInBrowser(rule.openInBrowser === 1);
};
const isSaving = createMutation.isPending || updateMutation.isPending;
const isMicrosoftDestination = destinationType === "teams" || destinationType === "sharepoint";
const saveDefaultExport = () => {
if (!importSettings) return;
if (defaultExportMode !== "browser" && !defaultDestinationPath.trim()) {
toast.error("Une destination est obligatoire lorsque l’export vers un dossier est activé");
return;
}
updateDefaultExportMutation.mutate({
manualImportEnabled: importSettings.manualImportEnabled,
autoImportEnabled: importSettings.autoImportEnabled,
emailImportEnabled: importSettings.emailImportEnabled,
exportFolder: defaultDestinationPath.trim() || null,
exportFolderType: defaultDestinationType,
bapExportMode: defaultExportMode,
});
};
return (
<div className="space-y-5">
<Card className="border-orange-200">
<CardHeader><CardTitle>Paramètres d’export par défaut</CardTitle><CardDescription>Ils s’appliquent lorsqu’aucun automatisme d’export ne correspond à la facture.</CardDescription></CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-4 md:grid-cols-2"><div className="space-y-2"><Label>Mode d’export</Label><Select value={defaultExportMode} onValueChange={(value) => setDefaultExportMode(value as "browser" | "folder" | "both")}><SelectTrigger><SelectValue /></SelectTrigger><SelectContent><SelectItem value="browser">Ouvrir dans le navigateur</SelectItem><SelectItem value="folder">Exporter vers la destination</SelectItem><SelectItem value="both">Destination et navigateur</SelectItem></SelectContent></Select></div><div className="space-y-2"><Label>Type de destination</Label><Select value={defaultDestinationType} onValueChange={(value) => setDefaultDestinationType(value as DestinationType)} disabled={defaultExportMode === "browser"}><SelectTrigger><SelectValue /></SelectTrigger><SelectContent><SelectItem value="local">Dossier local</SelectItem><SelectItem value="teams">Teams</SelectItem><SelectItem value="sharepoint">SharePoint</SelectItem></SelectContent></Select></div></div>
<div className="space-y-2"><Label htmlFor="default-export-path">{defaultDestinationType === "local" ? "Chemin du dossier local" : "URL SharePoint du dossier"}</Label><Input id="default-export-path" value={defaultDestinationPath} disabled={defaultExportMode === "browser"} onChange={(event) => setDefaultDestinationPath(event.target.value)} placeholder={defaultDestinationType === "local" ? "/chemin/serveur/factures-bap" : "https://…sharepoint.com/sites/…/Documents/Factures"} /></div>
{(defaultDestinationType === "teams" || defaultDestinationType === "sharepoint") && <p className="rounded-lg border border-cyan-200 bg-cyan-50/40 p-3 text-xs text-cyan-800">La configuration Microsoft Azure AD est centralisée dans <strong>Paramètres</strong>. Teams utilise l’URL SharePoint du dossier Fichiers du canal.</p>}
<Button onClick={saveDefaultExport} disabled={!importSettings || updateDefaultExportMutation.isPending}><Save className="mr-2 h-4 w-4" />{updateDefaultExportMutation.isPending ? "Enregistrement…" : "Enregistrer les paramètres d’export"}</Button>
</CardContent>
</Card>
<Card className="border-violet-200">
<CardHeader>
<CardTitle className="flex items-center gap-2"><FolderOpen className="h-5 w-5 text-violet-600" />Nouvelle règle de destination</CardTitle>
<CardDescription>La première règle active par priorité qui correspond à la facture est appliquée lors de la validation BAP.</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2"><Label htmlFor="export-rule-name">Nom de la règle</Label><Input id="export-rule-name" value={name} onChange={(event) => setName(event.target.value)} placeholder="Ex. DSI SANTINOVA vers SharePoint" /></div>
<div className="space-y-2"><Label>Critère</Label><Select value={conditionField} onValueChange={(value) => { setConditionField(value as ConditionField); setConditionValue(""); }}><SelectTrigger><SelectValue /></SelectTrigger><SelectContent><SelectItem value="recipientName">Destinataire</SelectItem><SelectItem value="ventilationComptable">Ventilation comptable</SelectItem></SelectContent></Select></div>
</div>
<div className="space-y-2"><Label htmlFor="export-rule-value">Valeur du critère</Label><Input id="export-rule-value" list="export-condition-values" value={conditionValue} onChange={(event) => setConditionValue(event.target.value)} placeholder={conditionField === "recipientName" ? "Choisir ou saisir un destinataire" : "Choisir ou saisir une ventilation"} /><datalist id="export-condition-values">{conditionValues.map((value) => <option value={value} key={value} />)}</datalist></div>
<div className="space-y-2"><Label>Destination</Label><div className="grid gap-2 sm:grid-cols-3">{(["local", "teams", "sharepoint"] as DestinationType[]).map((type) => <Button key={type} type="button" variant={destinationType === type ? "default" : "outline"} className={destinationType === type ? "bg-violet-600 hover:bg-violet-700" : ""} onClick={() => setDestinationType(type)}>{DESTINATION_LABELS[type]}</Button>)}</div></div>
<div className="space-y-2"><Label htmlFor="export-rule-path">{isMicrosoftDestination ? "URL SharePoint du dossier" : "Chemin du dossier local"}</Label><Input id="export-rule-path" value={destinationPath} onChange={(event) => setDestinationPath(event.target.value)} placeholder={isMicrosoftDestination ? "https://…sharepoint.com/sites/…/Documents/Factures" : "/chemin/serveur/factures-bap"} /><p className="text-xs text-muted-foreground">{isMicrosoftDestination ? "Pour Teams, indiquez l’URL SharePoint du dossier Fichiers du canal. Les droits Microsoft existants sont réutilisés." : "Ce chemin est créé si nécessaire sur le serveur qui héberge l’application."}</p></div>
<div className="flex items-center justify-between rounded-lg border bg-slate-50 p-3"><div><Label htmlFor="export-open-browser">Ouvrir aussi dans le navigateur</Label><p className="text-xs text-muted-foreground">Conserve le téléchargement dans le navigateur en complément de la destination définie.</p></div><Switch id="export-open-browser" checked={openInBrowser} onCheckedChange={setOpenInBrowser} /></div>
<div className="flex gap-2"><Button onClick={saveRule} disabled={isSaving}><Save className="mr-2 h-4 w-4" />{editingId ? "Mettre à jour" : "Créer la règle"}</Button>{editingId && <Button variant="outline" onClick={resetForm}>Annuler</Button>}</div>
</CardContent>
</Card>
<Card>
<CardHeader><CardTitle>Règles d’export</CardTitle><CardDescription>Les règles inactives restent enregistrées mais ne sont jamais appliquées.</CardDescription></CardHeader>
<CardContent>{isLoading ? <p className="text-sm text-muted-foreground">Chargement…</p> : rules.length === 0 ? <p className="text-sm text-muted-foreground">Aucune règle de destination. Les paramètres existants restent le comportement de repli.</p> : <Table><TableHeader><TableRow><TableHead>Nom</TableHead><TableHead>Condition</TableHead><TableHead>Destination</TableHead><TableHead>Statut</TableHead><TableHead className="w-32">Actions</TableHead></TableRow></TableHeader><TableBody>{rules.map((rule: any) => <TableRow key={rule.id}><TableCell className="font-medium">{rule.name}{rule.openInBrowser === 1 && <span className="ml-2 inline-flex items-center text-xs text-slate-500"><Monitor className="mr-1 h-3 w-3" />Navigateur</span>}</TableCell><TableCell>{LABELS[rule.conditionField as ConditionField]} : {rule.conditionValue}</TableCell><TableCell><Badge variant="outline">{DESTINATION_LABELS[rule.destinationType as DestinationType]}</Badge><div className="mt-1 max-w-xs truncate text-xs text-muted-foreground" title={rule.destinationPath}>{rule.destinationPath}</div></TableCell><TableCell>{rule.isActive === 1 ? <Badge className="bg-green-100 text-green-800 hover:bg-green-100">Actif</Badge> : <Badge variant="secondary">Inactif</Badge>}</TableCell><TableCell><div className="flex gap-1"><Button size="icon" variant="ghost" title={rule.isActive === 1 ? "Désactiver" : "Activer"} onClick={() => updateMutation.mutate({ id: rule.id, isActive: rule.isActive === 1 ? 0 : 1 })}>{rule.isActive === 1 ? <PowerOff className="h-4 w-4" /> : <Power className="h-4 w-4" />}</Button><Button size="icon" variant="ghost" title="Modifier" onClick={() => editRule(rule)}><Pencil className="h-4 w-4" /></Button><Button size="icon" variant="ghost" title="Supprimer" onClick={() => { if (window.confirm(`Supprimer la règle « ${rule.name} » ?`)) deleteMutation.mutate({ id: rule.id }); }}><Trash2 className="h-4 w-4 text-red-600" /></Button></div></TableCell></TableRow>)}</TableBody></Table>}</CardContent>
</Card>
</div>
);
}

View File

@@ -0,0 +1,96 @@
/**
* Paramètres d’affichage qui doivent survivre au passage par le détail d’une
* facture BAP. Seules ces clés sont sérialisées afin d’éviter de propager des
* paramètres de navigation arbitraires.
*/
export type BapFilters = {
searchQuery: string;
statusFilter: string;
selectedYear: string;
selectedMonth: string;
recipientFilter: string;
entityFilter: string;
ventilationFilter: string;
sortField: "invoiceDate" | "createdAt";
sortDir: "asc" | "desc";
};
const BAP_STATUSES = new Set([
"all",
"exported",
"not_exported",
"export_error",
"bap_validated",
"bap_pending",
"to_complete",
]);
function asSearchParams(search: string): URLSearchParams {
return new URLSearchParams(search.startsWith("?") ? search.slice(1) : search);
}
function isMonth(value: string | null): value is string {
return value !== null && /^(0[1-9]|1[0-2])$/.test(value);
}
function isYear(value: string | null): value is string {
return value === "all" || (value !== null && /^\d{4}$/.test(value));
}
function isSafeFilterValue(value: string | null): value is string {
return value !== null && value.length > 0 && value.length <= 200;
}
/** Lit et valide les filtres BAP transmis dans l’URL. */
export function parseBapFilters(search: string, defaultYear: string): BapFilters {
const params = asSearchParams(search);
const status = params.get("status");
const year = params.get("year");
const month = params.get("month");
const sort = params.get("sort");
const direction = params.get("dir");
return {
searchQuery: params.get("q") || "",
statusFilter: status && BAP_STATUSES.has(status) ? status : "all",
selectedYear: isYear(year) ? year : defaultYear,
selectedMonth: month === "all" || isMonth(month) ? month : "all",
recipientFilter: isSafeFilterValue(params.get("recipient")) ? params.get("recipient")! : "all",
entityFilter: ["all", "santinova", "itinova"].includes(params.get("entity") || "") ? params.get("entity")! : "all",
ventilationFilter: isSafeFilterValue(params.get("ventilation")) ? params.get("ventilation")! : "all",
sortField: sort === "invoiceDate" ? "invoiceDate" : "createdAt",
sortDir: direction === "asc" ? "asc" : "desc",
};
}
/** Construit une URL de détail qui conserve explicitement le contexte BAP. */
export function buildBapDetailLocation(invoiceId: number, filters: BapFilters): string {
const params = new URLSearchParams({
returnTo: "bap",
q: filters.searchQuery,
status: filters.statusFilter,
year: filters.selectedYear,
month: filters.selectedMonth,
recipient: filters.recipientFilter,
entity: filters.entityFilter,
ventilation: filters.ventilationFilter,
sort: filters.sortField,
dir: filters.sortDir,
});
return `/invoices/${invoiceId}?${params.toString()}`;
}
/** Retourne la liste d’origine. Sans contexte BAP, le comportement historique est conservé. */
export function getBapReturnLocation(search: string): string {
const params = asSearchParams(search);
if (params.get("returnTo") !== "bap") return "/invoices";
const allowed = new URLSearchParams();
for (const key of ["q", "status", "year", "month", "recipient", "entity", "ventilation", "sort", "dir"]) {
const value = params.get(key);
if (value) allowed.set(key, value);
}
const query = allowed.toString();
return query ? `/invoices-bap?${query}` : "/invoices-bap";
}

View File

@@ -1,4 +1,4 @@
import { useState } from "react";
import { useMemo, useState } from "react";
import DashboardLayout from "@/components/DashboardLayout";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
@@ -33,6 +33,13 @@ import {
} from "@/components/ui/table";
import { Badge } from "@/components/ui/badge";
import { trpc } from "@/lib/trpc";
import ExportAutomationRulesPanel from "@/components/ExportAutomationRulesPanel";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import {
AUTOMATION_ACTION_FILTERS,
type AutomationActionFilter,
matchesAutomationActionFilter,
} from "@shared/automationActions";
import { toast } from "sonner";
import { Plus, Edit, Trash2, Power, PowerOff, Copy } from "lucide-react";
@@ -46,6 +53,7 @@ interface Actions {
typeAchat?: string;
serviceConcerne?: string;
ventilationComptable?: string;
isSubscription?: 0 | 1;
}
export default function AutomationRules() {
@@ -62,6 +70,7 @@ export default function AutomationRules() {
const [wizardStep, setWizardStep] = useState(1);
const [testResultsOpen, setTestResultsOpen] = useState(false);
const [testResults, setTestResults] = useState<any>(null);
const [actionFilter, setActionFilter] = useState<AutomationActionFilter>("all");
// Form state
const [ruleName, setRuleName] = useState("");
@@ -84,6 +93,12 @@ export default function AutomationRules() {
return val;
};
const normalizeSubscriptionAction = (value: unknown): 0 | 1 | undefined => {
if (value === 1 || value === "1" || value === "OUI") return 1;
if (value === 0 || value === "0" || value === "NON") return 0;
return undefined;
};
const createMutation = trpc.automationRules.create.useMutation({
onSuccess: (rule) => {
if (rule.isActive === 1) {
@@ -204,7 +219,7 @@ export default function AutomationRules() {
} else {
setCustomVentilation("");
}
setActions(parsedActions);
setActions({ ...parsedActions, isSubscription: normalizeSubscriptionAction(parsedActions.isSubscription) });
} catch {
setActions({});
setCustomTypeAchat("");
@@ -231,11 +246,13 @@ export default function AutomationRules() {
typeAchat: resolveAction(actions.typeAchat, customTypeAchat),
serviceConcerne: resolveAction(actions.serviceConcerne, customServiceConcerne),
ventilationComptable: resolveAction(actions.ventilationComptable, customVentilation),
isSubscription: actions.isSubscription,
};
// Supprimer les clés undefined
if (!resolvedActions.typeAchat) delete resolvedActions.typeAchat;
if (!resolvedActions.serviceConcerne) delete resolvedActions.serviceConcerne;
if (!resolvedActions.ventilationComptable) delete resolvedActions.ventilationComptable;
if (resolvedActions.isSubscription === undefined) delete resolvedActions.isSubscription;
const actionsJSON = JSON.stringify(resolvedActions);
if (editingRule) {
@@ -311,13 +328,18 @@ export default function AutomationRules() {
{ value: "<=", label: "inférieur ou égal à" },
];
const filteredRules = useMemo(
() => rules.filter((rule) => matchesAutomationActionFilter(rule.actions, actionFilter)),
[rules, actionFilter],
);
return (
<DashboardLayout>
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold">Automatismes</h1>
<p className="text-gray-500 mt-1">Gérez les règles de remplissage automatique des champs</p>
<p className="text-gray-500 mt-1">Gérez séparément le classement à l’import et les destinations d’export</p>
</div>
<div className="flex gap-2">
<Button onClick={() => { setWizardMode(true); setWizardStep(1); openCreateDialog(); }}>
@@ -331,6 +353,12 @@ export default function AutomationRules() {
</div>
</div>
<Tabs defaultValue="import" className="space-y-4">
<TabsList className="h-11 bg-slate-100 p-1">
<TabsTrigger value="import" className="px-4 text-blue-700 hover:bg-blue-50 data-[state=active]:bg-blue-600 data-[state=active]:text-white data-[state=active]:shadow-sm">Automatismes d’import</TabsTrigger>
<TabsTrigger value="export" className="px-4 text-violet-700 hover:bg-violet-50 data-[state=active]:bg-violet-600 data-[state=active]:text-white data-[state=active]:shadow-sm">Automatismes d’export</TabsTrigger>
</TabsList>
<TabsContent value="import" className="space-y-4">
<Card>
<CardHeader>
<CardTitle>Règles d'automatisme</CardTitle>
@@ -339,12 +367,36 @@ export default function AutomationRules() {
</CardDescription>
</CardHeader>
<CardContent>
<div className="mb-4 flex flex-wrap items-center gap-3 rounded-lg border border-slate-200 bg-slate-50 p-3">
<Label htmlFor="automation-action-filter" className="font-medium text-slate-700">
Filtrer par action
</Label>
<Select value={actionFilter} onValueChange={(value) => setActionFilter(value as AutomationActionFilter)}>
<SelectTrigger id="automation-action-filter" className="w-[260px] bg-white">
<SelectValue placeholder="Toutes les actions" />
</SelectTrigger>
<SelectContent>
{AUTOMATION_ACTION_FILTERS.map((filter) => (
<SelectItem key={filter.value} value={filter.value}>
{filter.label}
</SelectItem>
))}
</SelectContent>
</Select>
<span className="text-sm text-slate-500">
{filteredRules.length} règle{filteredRules.length > 1 ? "s" : ""} affichée{filteredRules.length > 1 ? "s" : ""}
</span>
</div>
{isLoading ? (
<div className="text-center py-8 text-gray-500">Chargement...</div>
) : rules.length === 0 ? (
<div className="text-center py-8 text-gray-500">
Aucune règle d'automatisme configurée
</div>
) : filteredRules.length === 0 ? (
<div className="text-center py-8 text-gray-500">
Aucune règle ne correspond à cette action
</div>
) : (
<Table>
<TableHeader>
@@ -357,7 +409,7 @@ export default function AutomationRules() {
</TableRow>
</TableHeader>
<TableBody>
{rules.map((rule) => {
{filteredRules.map((rule) => {
let conditionsDisplay = "";
let actionsDisplay = "";
try {
@@ -372,6 +424,7 @@ export default function AutomationRules() {
if (acts.typeAchat) actionsArr.push(`Type: ${acts.typeAchat}`);
if (acts.serviceConcerne) actionsArr.push(`Service: ${acts.serviceConcerne}`);
if (acts.ventilationComptable) actionsArr.push(`Ventilation: ${acts.ventilationComptable}`);
if (acts.isSubscription !== undefined) actionsArr.push(`Abonnement: ${normalizeSubscriptionAction(acts.isSubscription) === 1 ? "Oui" : "Non"}`);
actionsDisplay = actionsArr.join(", ");
} catch {}
@@ -435,6 +488,11 @@ export default function AutomationRules() {
)}
</CardContent>
</Card>
</TabsContent>
<TabsContent value="export">
<ExportAutomationRulesPanel />
</TabsContent>
</Tabs>
{/* Create/Edit Dialog */}
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
@@ -610,6 +668,23 @@ export default function AutomationRules() {
/>
)}
</div>
<div className="space-y-2">
<Label htmlFor="isSubscription">Abonnement</Label>
<Select
value={actions.isSubscription === undefined ? "__NONE__" : String(actions.isSubscription)}
onValueChange={(value) => setActions({ ...actions, isSubscription: value === "__NONE__" ? undefined : Number(value) as 0 | 1 })}
>
<SelectTrigger>
<SelectValue placeholder="Ne pas modifier" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__NONE__">Ne pas modifier</SelectItem>
<SelectItem value="1">Oui</SelectItem>
<SelectItem value="0">Non</SelectItem>
</SelectContent>
</Select>
</div>
</div>
</div>
)}
@@ -642,7 +717,8 @@ export default function AutomationRules() {
{actions.typeAchat && <div>• Type d'achat : {actions.typeAchat}</div>}
{actions.serviceConcerne && <div>• Service : {actions.serviceConcerne}</div>}
{actions.ventilationComptable && <div>• Ventilation : {actions.ventilationComptable}</div>}
{!actions.typeAchat && !actions.serviceConcerne && !actions.ventilationComptable && (
{actions.isSubscription !== undefined && <div>• Abonnement : {actions.isSubscription === 1 ? "Oui" : "Non"}</div>}
{!actions.typeAchat && !actions.serviceConcerne && !actions.ventilationComptable && actions.isSubscription === undefined && (
<div className="text-gray-500 italic">Aucune action définie</div>
)}
</div>
@@ -745,6 +821,24 @@ export default function AutomationRules() {
<div className="space-y-2">
<Label>Actions (ALORS)</Label>
<div className="space-y-2">
<div className="space-y-2 rounded-md border border-emerald-200 bg-emerald-50 p-3">
<Label htmlFor="isSubscription" className="font-medium text-emerald-950">Abonnement</Label>
<Select
value={actions.isSubscription === undefined ? "__NONE__" : String(actions.isSubscription)}
onValueChange={(value) => setActions({ ...actions, isSubscription: value === "__NONE__" ? undefined : Number(value) as 0 | 1 })}
>
<SelectTrigger className="bg-white">
<SelectValue placeholder="Ne pas modifier" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__NONE__">Ne pas modifier</SelectItem>
<SelectItem value="1">Oui</SelectItem>
<SelectItem value="0">Non</SelectItem>
</SelectContent>
</Select>
<p className="text-xs text-emerald-800">Définit si les factures correspondant à la règle sont des abonnements.</p>
</div>
<div className="space-y-2">
<Label htmlFor="typeAchat">Type d'achat</Label>
<Select
@@ -829,6 +923,7 @@ export default function AutomationRules() {
/>
)}
</div>
</div>
</div>
</div>

View File

@@ -148,6 +148,38 @@ export default function Dashboard() {
</Card>
</div>
<Card>
<CardHeader>
<CardTitle>Récapitulatif annuel</CardTitle>
<CardDescription>Volumes et montants des factures finalisées, séparés entre BAP (hors abonnement) et abonnements.</CardDescription>
</CardHeader>
<CardContent>
{stats?.annualSummary?.length ? (
<div className="space-y-6">
<div className="h-[300px] rounded-lg border bg-white p-4">
<ResponsiveContainer width="100%" height="100%">
<BarChart data={[...stats.annualSummary].reverse()} margin={{ top: 8, right: 16, left: 12, bottom: 4 }}>
<CartesianGrid strokeDasharray="3 3" vertical={false} />
<XAxis dataKey="year" />
<YAxis tickFormatter={(value) => new Intl.NumberFormat("fr-FR", { notation: "compact", maximumFractionDigits: 1 }).format(value)} />
<Tooltip formatter={(value: number) => formatCurrency(Number(value))} labelFormatter={(year) => `Année ${year}`} />
<Legend />
<Bar dataKey="bapAmount" name="Montant BAP" fill="#2563eb" radius={[4, 4, 0, 0]} />
<Bar dataKey="subscriptionAmount" name="Montant abonnements" fill="#7c3aed" radius={[4, 4, 0, 0]} />
</BarChart>
</ResponsiveContainer>
</div>
<div className="overflow-x-auto rounded-lg border">
<table className="w-full min-w-[780px] text-sm">
<thead className="bg-muted/60 text-left text-muted-foreground"><tr><th className="px-4 py-3 font-medium">Année</th><th className="px-4 py-3 text-right font-medium">Factures BAP</th><th className="px-4 py-3 text-right font-medium">Montant BAP</th><th className="px-4 py-3 text-right font-medium">Abonnements</th><th className="px-4 py-3 text-right font-medium">Montant abonnements</th><th className="px-4 py-3 text-right font-medium">Total annuel</th></tr></thead>
<tbody>{stats.annualSummary.map((row) => <tr key={row.year} className="border-t hover:bg-muted/30"><td className="px-4 py-3 font-semibold">{row.year}</td><td className="px-4 py-3 text-right">{row.bapCount}</td><td className="px-4 py-3 text-right text-blue-700">{formatCurrency(row.bapAmount)}</td><td className="px-4 py-3 text-right">{row.subscriptionCount}</td><td className="px-4 py-3 text-right text-violet-700">{formatCurrency(row.subscriptionAmount)}</td><td className="px-4 py-3 text-right font-semibold text-emerald-700">{formatCurrency(row.totalAmount)}</td></tr>)}</tbody>
</table>
</div>
</div>
) : <div className="py-8 text-center text-muted-foreground">Aucune facture finalisée avec une date disponible.</div>}
</CardContent>
</Card>
{/* Detailed Stats */}
{showDetailedStats && (
<>

View File

@@ -1,6 +1,7 @@
import { useState, useMemo } from "react";
import { useMemo, useState } from "react";
import DashboardLayout from "@/components/DashboardLayout";
import { Card, CardContent } from "@/components/ui/card";
import { useAuth } from "@/_core/hooks/useAuth";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import {
Table,
TableBody,
@@ -12,20 +13,24 @@ import {
import { Badge } from "@/components/ui/badge";
import { trpc } from "@/lib/trpc";
import {
History as HistoryIcon,
FileText,
Trash2,
Eye,
CheckCircle2,
XCircle,
AlertTriangle,
CalendarDays,
RotateCcw,
Package,
X,
Upload,
CheckCircle2,
Clock3,
Eye,
FileText,
FolderOpen,
History as HistoryIcon,
Loader2,
Mail,
Package,
Play,
RotateCcw,
Trash2,
Upload,
UserRound,
X,
XCircle,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { toast } from "sonner";
@@ -55,6 +60,39 @@ import {
} from "@/components/ui/select";
type StatusFilter = "all" | "imported" | "not_imported" | "duplicates" | "errors";
type SourceFilter = "all" | "email" | "folder" | "file";
type TriggerFilter = "all" | "manual" | "automatic" | "unknown";
type DetailedImportLog = {
id: number;
userId: number;
sourceFileId: number;
fileName: string;
totalInvoicesDetected: number;
invoicesImported: number;
duplicatesIgnored: number;
errors: number;
duplicateDetails: string | null;
errorDetails: string | null;
warningMessage: string | null;
importSource: "file" | "folder" | "email";
importTrigger: "manual" | "automatic" | "unknown";
importedAt: Date;
userName: string | null;
userEmail: string | null;
sourceCreatedAt: Date | null;
sourceStatus: "processing" | "completed" | "error" | null;
};
type EmailAccount = {
userId: number;
userName: string | null;
userEmail: string;
emailAddress: string | null;
authMode: "basic" | "oauth2";
automaticEnabled: number;
isConfigured: number;
};
const MONTHS = [
{ value: "1", label: "Janvier" },
@@ -71,452 +109,327 @@ const MONTHS = [
{ value: "12", label: "Décembre" },
];
const YEARS = Array.from({ length: 5 }, (_, i) => String(new Date().getFullYear() - i));
const YEARS = Array.from({ length: 5 }, (_, index) => String(new Date().getFullYear() - index));
const IMPORT_LOG_PAGE_SIZE = 200;
function formatAccount(account: Pick<EmailAccount, "userName" | "userEmail" | "emailAddress">) {
const owner = account.userName?.trim() || account.userEmail;
return account.emailAddress ? `${owner} — ${account.emailAddress}` : owner;
}
function SourceBadge({ source }: { source: DetailedImportLog["importSource"] }) {
if (source === "email") {
return <Badge className="gap-1 border border-blue-200 bg-blue-100 text-blue-700 hover:bg-blue-100"><Mail className="h-3 w-3" />E-mail</Badge>;
}
if (source === "folder") {
return <Badge className="gap-1 border border-purple-200 bg-purple-100 text-purple-700 hover:bg-purple-100"><FolderOpen className="h-3 w-3" />Dossier</Badge>;
}
return <Badge className="gap-1 border border-slate-200 bg-slate-100 text-slate-700 hover:bg-slate-100"><Upload className="h-3 w-3" />Fichier</Badge>;
}
function TriggerBadge({ trigger }: { trigger: DetailedImportLog["importTrigger"] }) {
if (trigger === "manual") {
return <Badge variant="outline" className="border-teal-200 bg-teal-50 text-teal-700">Manuel</Badge>;
}
if (trigger === "automatic") {
return <Badge variant="outline" className="border-indigo-200 bg-indigo-50 text-indigo-700">Planifié</Badge>;
}
return <Badge variant="outline" className="border-slate-200 bg-slate-50 text-slate-500">Non tracé</Badge>;
}
export default function History() {
const { data: logs, isLoading } = trpc.importLogs.getByUser.useQuery();
const { user } = useAuth();
const isAdmin = user?.role === "admin";
const [page, setPage] = useState(0);
const { data: logsPage, isLoading } = trpc.importLogs.getByUser.useQuery({
limit: IMPORT_LOG_PAGE_SIZE,
offset: page * IMPORT_LOG_PAGE_SIZE,
});
const { data: emailAccounts, isLoading: accountsLoading } = trpc.emailImportService.getConfiguredAccounts.useQuery(undefined, {
enabled: isAdmin,
staleTime: 30_000,
});
const deleteAllMutation = trpc.importLogs.deleteAll.useMutation();
const checkAccountNowMutation = trpc.emailImportService.checkAccountNow.useMutation();
const utils = trpc.useUtils();
// Filtres
const detailedLogs = (logsPage?.items ?? []) as DetailedImportLog[];
const totalLogs = logsPage?.total ?? 0;
const accounts = (emailAccounts ?? []) as EmailAccount[];
const configuredAccounts = useMemo(() => accounts.filter((account) => account.isConfigured === 1), [accounts]);
const [statusFilter, setStatusFilter] = useState<StatusFilter>("all");
const [sourceFilter, setSourceFilter] = useState<SourceFilter>("all");
const [triggerFilter, setTriggerFilter] = useState<TriggerFilter>("all");
const [yearFilter, setYearFilter] = useState<string>(String(new Date().getFullYear()));
const [monthFilter, setMonthFilter] = useState<string>("all");
// Dialog visualisation détails
const [detailDialogOpen, setDetailDialogOpen] = useState(false);
const [selectedLog, setSelectedLog] = useState<(typeof logs extends (infer T)[] | undefined ? T : never) | null>(null);
const [selectedLog, setSelectedLog] = useState<DetailedImportLog | null>(null);
const [selectedAccountId, setSelectedAccountId] = useState("none");
const [confirmManualCheckOpen, setConfirmManualCheckOpen] = useState(false);
const [retentionMonths, setRetentionMonths] = useState<3 | 6 | 12>(6);
const { data: retentionPreview, isLoading: retentionPreviewLoading } = trpc.importLogs.retentionPreview.useQuery(
{ months: retentionMonths },
{ enabled: isAdmin },
);
const retentionPurgeMutation = trpc.importLogs.purgeBeforeRetention.useMutation();
const selectedAccount = useMemo(
() => configuredAccounts.find((account) => String(account.userId) === selectedAccountId),
[configuredAccounts, selectedAccountId],
);
const handleDeleteAll = async () => {
try {
await deleteAllMutation.mutateAsync();
await utils.importLogs.getByUser.invalidate();
toast.success("Logs supprimés", {
description: "Tous les logs d'import ont été supprimés avec succès.",
});
toast.success("Historique supprimé", { description: "Les enregistrements visibles pour votre rôle ont été supprimés." });
} catch {
toast.error("Erreur", {
description: "Impossible de supprimer les logs d'import.",
});
toast.error("Impossible de supprimer l’historique des imports.");
}
};
// Filtrage par période
const periodFiltered = useMemo(() => {
if (!logs) return [];
return logs.filter((log) => {
const d = new Date(log.importedAt);
if (yearFilter !== "all" && d.getFullYear() !== Number(yearFilter)) return false;
if (monthFilter !== "all" && (d.getMonth() + 1) !== Number(monthFilter)) return false;
return true;
});
}, [logs, yearFilter, monthFilter]);
const handleRetentionPurge = async () => {
try {
const result = await retentionPurgeMutation.mutateAsync({ months: retentionMonths });
setPage(0);
await Promise.all([
utils.importLogs.getByUser.invalidate(),
utils.importLogs.retentionPreview.invalidate(),
]);
toast.success("Purge de l’historique terminée", {
description: `${result.deletedCount.toLocaleString("fr-FR")} journal${result.deletedCount > 1 ? "x" : ""} antérieur${result.deletedCount > 1 ? "s" : ""} à ${result.months} mois supprimé${result.deletedCount > 1 ? "s" : ""}.`,
});
} catch (error: any) {
toast.error(error?.message || "Impossible de purger l’historique des imports.");
}
};
const handleManualEmailCheck = async () => {
if (!selectedAccount) return;
try {
toast.info("Lecture ponctuelle de la boîte e-mail en cours…");
const result = await checkAccountNowMutation.mutateAsync({ userId: selectedAccount.userId });
if (!result.success) {
toast.error(result.message);
return;
}
await utils.importLogs.getByUser.invalidate();
toast.success("Lecture e-mail terminée", { description: result.message });
} catch (error: any) {
toast.error(error?.message || "Impossible de vérifier cette boîte e-mail.");
} finally {
setConfirmManualCheckOpen(false);
}
};
const periodFiltered = useMemo(() => detailedLogs.filter((log) => {
const date = new Date(log.importedAt);
if (yearFilter !== "all" && date.getFullYear() !== Number(yearFilter)) return false;
if (monthFilter !== "all" && date.getMonth() + 1 !== Number(monthFilter)) return false;
return true;
}), [detailedLogs, yearFilter, monthFilter]);
// Compteurs sur la période filtrée
const counts = useMemo(() => {
const totalImported = periodFiltered.reduce((s, l) => s + l.invoicesImported, 0);
const totalDuplicates = periodFiltered.reduce((s, l) => s + l.duplicatesIgnored, 0);
const totalErrors = periodFiltered.reduce((s, l) => s + l.errors, 0);
const totalDetected = periodFiltered.reduce((s, l) => s + l.totalInvoicesDetected, 0);
const totalNotImported = totalDuplicates + totalErrors;
return { totalImported, totalDuplicates, totalErrors, totalDetected, totalNotImported };
const totalImported = periodFiltered.reduce((sum, log) => sum + log.invoicesImported, 0);
const totalDuplicates = periodFiltered.reduce((sum, log) => sum + log.duplicatesIgnored, 0);
const totalErrors = periodFiltered.reduce((sum, log) => sum + log.errors, 0);
const totalDetected = periodFiltered.reduce((sum, log) => sum + log.totalInvoicesDetected, 0);
return { totalImported, totalDuplicates, totalErrors, totalDetected, totalNotImported: totalDuplicates + totalErrors };
}, [periodFiltered]);
// Filtrage par statut
const filteredLogs = useMemo(() => {
return periodFiltered.filter((log) => {
if (statusFilter === "all") return true;
if (statusFilter === "imported") return log.invoicesImported > 0;
if (statusFilter === "not_imported") return log.duplicatesIgnored > 0 || log.errors > 0;
if (statusFilter === "duplicates") return log.duplicatesIgnored > 0;
if (statusFilter === "errors") return log.errors > 0;
return true;
});
}, [periodFiltered, statusFilter]);
const filteredLogs = useMemo(() => periodFiltered.filter((log) => {
if (sourceFilter !== "all" && log.importSource !== sourceFilter) return false;
if (triggerFilter !== "all" && log.importTrigger !== triggerFilter) return false;
if (statusFilter === "imported") return log.invoicesImported > 0;
if (statusFilter === "not_imported") return log.duplicatesIgnored > 0 || log.errors > 0;
if (statusFilter === "duplicates") return log.duplicatesIgnored > 0;
if (statusFilter === "errors") return log.errors > 0;
return true;
}), [periodFiltered, sourceFilter, statusFilter, triggerFilter]);
const hasPeriodFilter = yearFilter !== "all" || monthFilter !== "all";
const filterButtons: { key: StatusFilter; label: string; count: number; color: string; activeColor: string }[] = [
{ key: "all", label: "Tous", count: periodFiltered.length, color: "bg-gray-100 text-gray-700 hover:bg-gray-200", activeColor: "bg-gray-700 text-white" },
{ key: "imported", label: "Importées", count: counts.totalImported, color: "bg-green-50 text-green-700 hover:bg-green-100 border border-green-200", activeColor: "bg-green-600 text-white" },
{ key: "not_imported", label: "Non importées", count: counts.totalNotImported, color: "bg-orange-50 text-orange-700 hover:bg-orange-100 border border-orange-200", activeColor: "bg-orange-500 text-white" },
{ key: "duplicates", label: "Doublons", count: counts.totalDuplicates, color: "bg-yellow-50 text-yellow-700 hover:bg-yellow-100 border border-yellow-200", activeColor: "bg-yellow-500 text-white" },
{ key: "errors", label: "Erreurs", count: counts.totalErrors, color: "bg-red-50 text-red-700 hover:bg-red-100 border border-red-200", activeColor: "bg-red-600 text-white" },
const statusButtons: { key: StatusFilter; label: string; count: number; base: string; active: string }[] = [
{ key: "all", label: "Tous", count: periodFiltered.length, base: "bg-slate-100 text-slate-700 hover:bg-slate-200", active: "bg-slate-700 text-white" },
{ key: "imported", label: "Importées", count: counts.totalImported, base: "border border-green-200 bg-green-50 text-green-700 hover:bg-green-100", active: "bg-green-600 text-white" },
{ key: "not_imported", label: "Non importées", count: counts.totalNotImported, base: "border border-orange-200 bg-orange-50 text-orange-700 hover:bg-orange-100", active: "bg-orange-500 text-white" },
{ key: "duplicates", label: "Doublons", count: counts.totalDuplicates, base: "border border-yellow-200 bg-yellow-50 text-yellow-700 hover:bg-yellow-100", active: "bg-yellow-500 text-white" },
{ key: "errors", label: "Erreurs", count: counts.totalErrors, base: "border border-red-200 bg-red-50 text-red-700 hover:bg-red-100", active: "bg-red-600 text-white" },
];
const hasFilters = yearFilter !== "all" || monthFilter !== "all" || sourceFilter !== "all" || triggerFilter !== "all";
return (
<DashboardLayout>
<div className="space-y-6">
{/* En-tête */}
<div className="flex justify-between items-start">
<div className="flex flex-col justify-between gap-4 sm:flex-row sm:items-start">
<div>
<h1 className="text-3xl font-bold">Historique des imports</h1>
<p className="text-gray-500 mt-1">Consultez l'historique de tous vos imports de factures</p>
<p className="mt-1 text-gray-500">Suivez la date, la source, le compte et le mode de déclenchement de chaque ajout.</p>
</div>
{logs && logs.length > 0 && (
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="destructive" size="sm">
<Trash2 className="mr-2 h-4 w-4" />
Effacer les logs
<div className="flex flex-wrap items-center gap-2">
{isAdmin && (
<>
<Select value={String(retentionMonths)} onValueChange={(value) => setRetentionMonths(Number(value) as 3 | 6 | 12)}>
<SelectTrigger className="h-9 w-40 text-sm"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="3">Conserver 3 mois</SelectItem>
<SelectItem value="6">Conserver 6 mois</SelectItem>
<SelectItem value="12">Conserver 1 an</SelectItem>
</SelectContent>
</Select>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="outline" size="sm" className="border-orange-300 bg-orange-50 text-orange-800 hover:bg-orange-100"><Trash2 className="mr-2 h-4 w-4" />Purger l’historique</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Purger les journaux anciens ?</AlertDialogTitle>
<AlertDialogDescription>
{retentionPreviewLoading ? "Calcul du volume à purger…" : `${(retentionPreview?.count ?? 0).toLocaleString("fr-FR")} journal${(retentionPreview?.count ?? 0) > 1 ? "x" : ""} antérieur${(retentionPreview?.count ?? 0) > 1 ? "s" : ""} à ${retentionMonths} mois seront supprimés.`}
{" Les factures, fichiers source et PDF ne seront pas supprimés."}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Annuler</AlertDialogCancel>
<AlertDialogAction onClick={handleRetentionPurge} disabled={retentionPreviewLoading || retentionPurgeMutation.isPending} className="bg-destructive text-destructive-foreground hover:bg-destructive/90">
{retentionPurgeMutation.isPending ? "Purge…" : "Confirmer la purge"}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
)}
{detailedLogs.length > 0 && (
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="destructive" size="sm"><Trash2 className="mr-2 h-4 w-4" />Effacer l’historique</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Supprimer l’historique ?</AlertDialogTitle>
<AlertDialogDescription>Cette action est irréversible et supprime les enregistrements accessibles avec votre rôle.</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Annuler</AlertDialogCancel>
<AlertDialogAction onClick={handleDeleteAll} disabled={deleteAllMutation.isPending} className="bg-destructive text-destructive-foreground hover:bg-destructive/90">
{deleteAllMutation.isPending ? "Suppression…" : "Supprimer"}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)}
</div>
</div>
{isAdmin && (
<Card className="border-teal-200 bg-gradient-to-r from-teal-50 to-cyan-50">
<CardHeader className="pb-4">
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div className="flex gap-3">
<div className="rounded-lg bg-teal-600 p-2 text-white"><Mail className="h-5 w-5" /></div>
<div>
<CardTitle className="text-lg">Lecture e-mail ponctuelle</CardTitle>
<CardDescription>Déclenchez une seule vérification pour une boîte configurée, sans réactiver le traitement automatique.</CardDescription>
</div>
</div>
<Badge variant="outline" className="w-fit border-teal-200 bg-white text-teal-700">Aucune planification créée</Badge>
</div>
</CardHeader>
<CardContent className="flex flex-col gap-3 sm:flex-row sm:items-end">
<div className="min-w-0 flex-1 space-y-2">
<label className="text-sm font-medium" htmlFor="manual-email-account">Compte à vérifier</label>
<Select value={selectedAccountId} onValueChange={setSelectedAccountId} disabled={accountsLoading || configuredAccounts.length === 0}>
<SelectTrigger id="manual-email-account" className="bg-white"><SelectValue placeholder={accountsLoading ? "Chargement des comptes…" : "Choisir une boîte e-mail"} /></SelectTrigger>
<SelectContent>
<SelectItem value="none">Choisir une boîte e-mail</SelectItem>
{configuredAccounts.map((account) => <SelectItem key={account.userId} value={String(account.userId)}>{formatAccount(account)}</SelectItem>)}
</SelectContent>
</Select>
</div>
<AlertDialog open={confirmManualCheckOpen} onOpenChange={setConfirmManualCheckOpen}>
<Button onClick={() => setConfirmManualCheckOpen(true)} disabled={!selectedAccount || checkAccountNowMutation.isPending} className="bg-teal-600 hover:bg-teal-700">
{checkAccountNowMutation.isPending ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <Play className="mr-2 h-4 w-4" />}Vérifier maintenant
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Êtes-vous sûr ?</AlertDialogTitle>
<AlertDialogDescription>
Cette action supprimera définitivement tous les logs d'import. Cette opération est irréversible.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Annuler</AlertDialogCancel>
<AlertDialogAction
onClick={handleDeleteAll}
disabled={deleteAllMutation.isPending}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
{deleteAllMutation.isPending ? "Suppression..." : "Supprimer"}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)}
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Lancer une lecture e-mail ponctuelle ?</AlertDialogTitle>
<AlertDialogDescription>
La boîte « {selectedAccount ? formatAccount(selectedAccount) : ""} » sera vérifiée une fois. Les nouveaux PDF pourront être analysés, mais l’import automatique restera désactivé.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Annuler</AlertDialogCancel>
<AlertDialogAction onClick={handleManualEmailCheck} disabled={checkAccountNowMutation.isPending} className="bg-teal-600 hover:bg-teal-700">
{checkAccountNowMutation.isPending ? "Vérification…" : "Lancer la vérification"}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</CardContent>
</Card>
)}
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
<Card className="border-green-200 bg-green-50"><CardContent className="flex items-center gap-3 p-4"><CheckCircle2 className="h-8 w-8 shrink-0 text-green-600" /><div><p className="text-2xl font-bold text-green-700">{counts.totalImported}</p><p className="text-xs font-medium text-green-600">Factures importées</p></div></CardContent></Card>
<Card className="border-orange-200 bg-orange-50"><CardContent className="flex items-center gap-3 p-4"><XCircle className="h-8 w-8 shrink-0 text-orange-500" /><div><p className="text-2xl font-bold text-orange-600">{counts.totalNotImported}</p><p className="text-xs font-medium text-orange-500">Non importées</p></div></CardContent></Card>
<Card className="border-yellow-200 bg-yellow-50"><CardContent className="flex items-center gap-3 p-4"><RotateCcw className="h-8 w-8 shrink-0 text-yellow-600" /><div><p className="text-2xl font-bold text-yellow-700">{counts.totalDuplicates}</p><p className="text-xs font-medium text-yellow-600">Doublons ignorés</p></div></CardContent></Card>
<Card className="border-red-200 bg-red-50"><CardContent className="flex items-center gap-3 p-4"><AlertTriangle className="h-8 w-8 shrink-0 text-red-500" /><div><p className="text-2xl font-bold text-red-600">{counts.totalErrors}</p><p className="text-xs font-medium text-red-600">Erreurs</p></div></CardContent></Card>
</div>
{/* Compteurs */}
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
<Card className="border-green-200 bg-green-50">
<CardContent className="p-4 flex items-center gap-3">
<CheckCircle2 className="h-8 w-8 text-green-600 shrink-0" />
<div>
<p className="text-2xl font-bold text-green-700">{counts.totalImported}</p>
<p className="text-xs text-green-600 font-medium">Factures importées</p>
</div>
</CardContent>
</Card>
<Card className="border-orange-200 bg-orange-50">
<CardContent className="p-4 flex items-center gap-3">
<XCircle className="h-8 w-8 text-orange-500 shrink-0" />
<div>
<p className="text-2xl font-bold text-orange-600">{counts.totalNotImported}</p>
<p className="text-xs text-orange-500 font-medium">Non importées</p>
</div>
</CardContent>
</Card>
<Card className="border-yellow-200 bg-yellow-50">
<CardContent className="p-4 flex items-center gap-3">
<RotateCcw className="h-8 w-8 text-yellow-600 shrink-0" />
<div>
<p className="text-2xl font-bold text-yellow-700">{counts.totalDuplicates}</p>
<p className="text-xs text-yellow-600 font-medium">Doublons ignorés</p>
</div>
</CardContent>
</Card>
<Card className="border-red-200 bg-red-50">
<CardContent className="p-4 flex items-center gap-3">
<AlertTriangle className="h-8 w-8 text-red-500 shrink-0" />
<div>
<p className="text-2xl font-bold text-red-600">{counts.totalErrors}</p>
<p className="text-xs text-red-500 font-medium">Erreurs</p>
</div>
</CardContent>
</Card>
</div>
{/* Filtres */}
<div className="flex flex-wrap gap-3 items-center">
{/* Filtres statut */}
<Card className="border-blue-100 bg-blue-50/40"><CardContent className="flex flex-wrap items-center gap-3 p-4">
<div className="flex flex-wrap gap-2">
{filterButtons.map((btn) => (
<button
key={btn.key}
onClick={() => setStatusFilter(btn.key)}
className={`px-3 py-1.5 rounded-full text-sm font-medium transition-all ${
statusFilter === btn.key ? btn.activeColor : btn.color
}`}
>
{btn.label}
<span className={`ml-1.5 inline-flex items-center justify-center rounded-full text-xs px-1.5 py-0.5 font-bold ${
statusFilter === btn.key ? "bg-white/20" : "bg-white/60"
}`}>
{btn.count}
</span>
</button>
))}
{statusButtons.map((button) => <button key={button.key} onClick={() => setStatusFilter(button.key)} className={`rounded-full px-3 py-1.5 text-sm font-medium transition-colors ${statusFilter === button.key ? button.active : button.base}`}>
{button.label}<span className={`ml-1.5 rounded-full px-1.5 py-0.5 text-xs font-bold ${statusFilter === button.key ? "bg-white/20" : "bg-white/70"}`}>{button.count}</span>
</button>)}
</div>
{/* Séparateur */}
<div className="h-6 w-px bg-gray-200" />
{/* Filtre Période */}
<div className="flex items-center gap-2">
<div className="hidden h-6 w-px bg-blue-200 sm:block" />
<div className="flex flex-wrap items-center gap-2">
<CalendarDays className="h-4 w-4 text-gray-500" />
<Select value={yearFilter} onValueChange={(v) => { setYearFilter(v); if (v === "all") setMonthFilter("all"); }}>
<SelectTrigger className="h-8 w-28 text-sm">
<SelectValue placeholder="Année" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Toute année</SelectItem>
{YEARS.map((y) => (
<SelectItem key={y} value={y}>{y}</SelectItem>
))}
</SelectContent>
</Select>
<Select
value={monthFilter}
onValueChange={setMonthFilter}
disabled={yearFilter === "all"}
>
<SelectTrigger className="h-8 w-32 text-sm">
<SelectValue placeholder="Mois" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Tous les mois</SelectItem>
{MONTHS.map((m) => (
<SelectItem key={m.value} value={m.value}>{m.label}</SelectItem>
))}
</SelectContent>
</Select>
{hasPeriodFilter && (
<Button
variant="ghost"
size="sm"
className="h-8 px-2 text-gray-500 hover:text-gray-700"
onClick={() => { setYearFilter("all"); setMonthFilter("all"); }}
title="Réinitialiser la période"
>
<X className="h-4 w-4" />
</Button>
)}
<Select value={yearFilter} onValueChange={(value) => { setYearFilter(value); if (value === "all") setMonthFilter("all"); }}><SelectTrigger className="h-8 w-28 bg-white text-sm"><SelectValue placeholder="Année" /></SelectTrigger><SelectContent><SelectItem value="all">Toute année</SelectItem>{YEARS.map((year) => <SelectItem key={year} value={year}>{year}</SelectItem>)}</SelectContent></Select>
<Select value={monthFilter} onValueChange={setMonthFilter} disabled={yearFilter === "all"}><SelectTrigger className="h-8 w-32 bg-white text-sm"><SelectValue placeholder="Mois" /></SelectTrigger><SelectContent><SelectItem value="all">Tous les mois</SelectItem>{MONTHS.map((month) => <SelectItem key={month.value} value={month.value}>{month.label}</SelectItem>)}</SelectContent></Select>
<Select value={sourceFilter} onValueChange={(value) => setSourceFilter(value as SourceFilter)}><SelectTrigger className="h-8 w-28 bg-white text-sm"><SelectValue placeholder="Source" /></SelectTrigger><SelectContent><SelectItem value="all">Toute source</SelectItem><SelectItem value="email">E-mail</SelectItem><SelectItem value="folder">Dossier</SelectItem><SelectItem value="file">Fichier</SelectItem></SelectContent></Select>
<Select value={triggerFilter} onValueChange={(value) => setTriggerFilter(value as TriggerFilter)}><SelectTrigger className="h-8 w-32 bg-white text-sm"><SelectValue placeholder="Déclencheur" /></SelectTrigger><SelectContent><SelectItem value="all">Tout mode</SelectItem><SelectItem value="manual">Manuel</SelectItem><SelectItem value="automatic">Planifié</SelectItem><SelectItem value="unknown">Non tracé</SelectItem></SelectContent></Select>
{hasFilters && <Button variant="ghost" size="sm" className="h-8 px-2 text-gray-500" onClick={() => { setYearFilter("all"); setMonthFilter("all"); setSourceFilter("all"); setTriggerFilter("all"); }} title="Réinitialiser les filtres"><X className="h-4 w-4" /></Button>}
</div>
<div className="ml-auto flex items-center gap-2 text-sm text-gray-500">
<span>{filteredLogs.length} import{filteredLogs.length > 1 ? "s" : ""} · {counts.totalDetected} facture{counts.totalDetected > 1 ? "s" : ""} détectée{counts.totalDetected > 1 ? "s" : ""}</span>
{totalLogs > IMPORT_LOG_PAGE_SIZE && <>
<Button variant="outline" size="sm" className="h-8" disabled={page === 0} onClick={() => setPage((current) => current - 1)}>Précédent</Button>
<span className="whitespace-nowrap">Page {page + 1}</span>
<Button variant="outline" size="sm" className="h-8" disabled={(page + 1) * IMPORT_LOG_PAGE_SIZE >= totalLogs} onClick={() => setPage((current) => current + 1)}>Suivant</Button>
</>}
</div>
</CardContent></Card>
{/* Résumé */}
<span className="text-sm text-gray-500 ml-auto">
{filteredLogs.length} import{filteredLogs.length > 1 ? "s" : ""} — {counts.totalDetected} facture{counts.totalDetected > 1 ? "s" : ""} détectée{counts.totalDetected > 1 ? "s" : ""}
</span>
</div>
{/* Tableau */}
<Card>
<CardContent className="p-0">
{isLoading ? (
<div className="text-center py-12 text-gray-500">
<Package className="w-10 h-10 mx-auto mb-3 text-gray-300 animate-pulse" />
<p>Chargement...</p>
</div>
) : filteredLogs.length > 0 ? (
<Table>
<TableHeader>
<TableRow className="bg-gray-50">
<TableHead className="font-semibold">Fichier importé</TableHead>
<TableHead className="font-semibold">Date d'import</TableHead>
<TableHead className="font-semibold text-center">Source</TableHead>
<TableHead className="font-semibold text-center">Détectées</TableHead>
<TableHead className="font-semibold text-center">Importées</TableHead>
<TableHead className="font-semibold text-center">Doublons</TableHead>
<TableHead className="font-semibold text-center">Erreurs</TableHead>
<TableHead className="font-semibold text-center w-20">Détails</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredLogs.map((log) => (
<TableRow key={log.id} className="hover:bg-gray-50/50 transition-colors">
<TableCell className="font-medium max-w-xs truncate" title={log.fileName}>
<div className="flex items-center gap-2">
<FileText className="h-4 w-4 text-blue-500 shrink-0" />
<span className="truncate">{log.fileName}</span>
</div>
</TableCell>
<TableCell className="text-sm text-gray-600">
{new Date(log.importedAt).toLocaleString("fr-FR")}
</TableCell>
<TableCell className="text-center">
{log.importSource === "email" ? (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-700 border border-blue-200">
<Mail className="h-3 w-3" />
Email
</span>
) : log.importSource === "folder" ? (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium bg-purple-100 text-purple-700 border border-purple-200">
<FolderOpen className="h-3 w-3" />
Dossier
</span>
) : (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-700 border border-gray-200">
<Upload className="h-3 w-3" />
Fichier
</span>
)}
</TableCell>
<TableCell className="text-center">
<Badge variant="outline" className="font-mono">
{log.totalInvoicesDetected}
</Badge>
</TableCell>
<TableCell className="text-center">
{log.invoicesImported > 0 ? (
<Badge className="bg-green-100 text-green-800 hover:bg-green-100 font-mono">
{log.invoicesImported}
</Badge>
) : (
<span className="text-gray-400 text-sm">0</span>
)}
</TableCell>
<TableCell className="text-center">
{log.duplicatesIgnored > 0 ? (
<Badge className="bg-yellow-100 text-yellow-800 hover:bg-yellow-100 font-mono">
{log.duplicatesIgnored}
</Badge>
) : (
<span className="text-gray-400">-</span>
)}
</TableCell>
<TableCell className="text-center">
{log.errors > 0 ? (
<Badge className="bg-red-100 text-red-800 hover:bg-red-100 font-mono">
{log.errors}
</Badge>
) : (
<span className="text-gray-400">-</span>
)}
</TableCell>
<TableCell className="text-center">
<Button
size="sm"
variant="outline"
className="h-8 px-2 text-blue-600 border-blue-200 hover:bg-blue-50 hover:border-blue-400"
title="Voir les détails de cet import"
onClick={() => {
setSelectedLog(log);
setDetailDialogOpen(true);
}}
>
<Eye className="h-4 w-4" />
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
) : (
<div className="text-center py-12 text-gray-500">
<HistoryIcon className="w-12 h-12 mx-auto mb-3 text-gray-300" />
<p className="font-medium">Aucun import trouvé</p>
<p className="text-sm mt-1">Modifiez les filtres pour afficher plus de résultats</p>
</div>
)}
</CardContent>
</Card>
<Card><CardContent className="overflow-x-auto p-0">
{isLoading ? <div className="py-12 text-center text-gray-500"><Package className="mx-auto mb-3 h-10 w-10 animate-pulse text-gray-300" /><p>Chargement de l’historique…</p></div> : filteredLogs.length > 0 ? (
<Table><TableHeader><TableRow className="bg-gray-50"><TableHead className="font-semibold">Fichier importé</TableHead><TableHead className="font-semibold">Date et heure</TableHead><TableHead className="font-semibold">Compte</TableHead><TableHead className="text-center font-semibold">Source</TableHead><TableHead className="text-center font-semibold">Mode</TableHead><TableHead className="text-center font-semibold">Résultat</TableHead><TableHead className="w-20 text-center font-semibold">Détails</TableHead></TableRow></TableHeader>
<TableBody>{filteredLogs.map((log) => <TableRow key={log.id} className="hover:bg-gray-50/50"><TableCell className="max-w-xs font-medium" title={log.fileName}><div className="flex items-center gap-2"><FileText className="h-4 w-4 shrink-0 text-blue-500" /><span className="truncate">{log.fileName}</span></div></TableCell><TableCell className="whitespace-nowrap text-sm text-gray-600"><div className="flex items-center gap-1"><Clock3 className="h-3.5 w-3.5 text-gray-400" />{new Date(log.importedAt).toLocaleString("fr-FR")}</div></TableCell><TableCell className="max-w-48 text-sm text-gray-600"><div className="flex items-center gap-1.5 truncate"><UserRound className="h-3.5 w-3.5 shrink-0 text-gray-400" /><span className="truncate" title={log.userEmail || "Compte supprimé"}>{log.userName?.trim() || log.userEmail || "Compte supprimé"}</span></div></TableCell><TableCell className="text-center"><SourceBadge source={log.importSource} /></TableCell><TableCell className="text-center"><TriggerBadge trigger={log.importTrigger} /></TableCell><TableCell className="text-center"><span className="font-mono text-sm text-green-700">+{log.invoicesImported}</span>{log.duplicatesIgnored > 0 && <span className="ml-1 font-mono text-xs text-yellow-700">/ {log.duplicatesIgnored} d.</span>}{log.errors > 0 && <span className="ml-1 font-mono text-xs text-red-700">/ {log.errors} e.</span>}</TableCell><TableCell className="text-center"><Button size="sm" variant="outline" className="h-8 px-2 text-blue-600" title="Voir les détails de cet import" onClick={() => { setSelectedLog(log); setDetailDialogOpen(true); }}><Eye className="h-4 w-4" /></Button></TableCell></TableRow>)}</TableBody>
</Table>
) : <div className="py-12 text-center text-gray-500"><HistoryIcon className="mx-auto mb-3 h-12 w-12 text-gray-300" /><p className="font-medium">Aucun import trouvé</p><p className="mt-1 text-sm">Modifiez les filtres pour afficher plus de résultats.</p></div>}
</CardContent></Card>
</div>
{/* Dialog visualisation détails */}
<Dialog open={detailDialogOpen} onOpenChange={setDetailDialogOpen}>
<DialogContent className="max-w-2xl max-h-[80vh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<FileText className="h-5 w-5 text-blue-500" />
Détails de l'import
</DialogTitle>
</DialogHeader>
{selectedLog && (
<div className="space-y-4">
{/* Infos générales */}
<div className="grid grid-cols-2 gap-3">
<div className="bg-gray-50 rounded-lg p-3">
<p className="text-xs text-gray-500 font-medium mb-1">Fichier</p>
<p className="text-sm font-semibold break-all">{selectedLog.fileName}</p>
</div>
<div className="bg-gray-50 rounded-lg p-3">
<p className="text-xs text-gray-500 font-medium mb-1">Date d'import</p>
<p className="text-sm font-semibold">{new Date(selectedLog.importedAt).toLocaleString("fr-FR")}</p>
</div>
<div className="bg-gray-50 rounded-lg p-3">
<p className="text-xs text-gray-500 font-medium mb-1">Source import</p>
<p className="text-sm font-semibold">
{selectedLog.importSource === "email" ? (
<span className="inline-flex items-center gap-1 text-blue-700"><Mail className="h-4 w-4" /> Email</span>
) : selectedLog.importSource === "folder" ? (
<span className="inline-flex items-center gap-1 text-purple-700"><FolderOpen className="h-4 w-4" /> Dossier</span>
) : (
<span className="inline-flex items-center gap-1 text-gray-700"><Upload className="h-4 w-4" /> Fichier</span>
)}
</p>
</div>
</div>
{/* Statistiques */}
<div className="grid grid-cols-4 gap-2">
<div className="text-center bg-gray-50 rounded-lg p-3">
<p className="text-2xl font-bold text-gray-700">{selectedLog.totalInvoicesDetected}</p>
<p className="text-xs text-gray-500 mt-1">Détectées</p>
</div>
<div className="text-center bg-green-50 rounded-lg p-3">
<p className="text-2xl font-bold text-green-700">{selectedLog.invoicesImported}</p>
<p className="text-xs text-green-600 mt-1">Importées</p>
</div>
<div className="text-center bg-yellow-50 rounded-lg p-3">
<p className="text-2xl font-bold text-yellow-700">{selectedLog.duplicatesIgnored}</p>
<p className="text-xs text-yellow-600 mt-1">Doublons</p>
</div>
<div className="text-center bg-red-50 rounded-lg p-3">
<p className="text-2xl font-bold text-red-600">{selectedLog.errors}</p>
<p className="text-xs text-red-500 mt-1">Erreurs</p>
</div>
</div>
{/* Détails doublons */}
{selectedLog.duplicateDetails && (() => {
try {
const details = JSON.parse(selectedLog.duplicateDetails);
if (Array.isArray(details) && details.length > 0) {
return (
<div>
<h3 className="text-sm font-semibold text-yellow-700 mb-2 flex items-center gap-1">
<RotateCcw className="h-4 w-4" />
Doublons ignorés ({details.length})
</h3>
<div className="space-y-1 max-h-40 overflow-y-auto">
{details.map((d: string | { supplier?: string; invoiceNumber?: string; reason?: string }, i: number) => (
<div key={i} className="text-xs bg-yellow-50 border border-yellow-100 rounded px-3 py-1.5 text-yellow-800">
{typeof d === "string" ? d : `${d.supplier || ""} — ${d.invoiceNumber || ""} ${d.reason ? `(${d.reason})` : ""}`}
</div>
))}
</div>
</div>
);
}
} catch { /* JSON invalide */ }
return null;
})()}
{/* Détails erreurs */}
{selectedLog.errorDetails && (() => {
try {
const details = JSON.parse(selectedLog.errorDetails);
if (Array.isArray(details) && details.length > 0) {
return (
<div>
<h3 className="text-sm font-semibold text-red-600 mb-2 flex items-center gap-1">
<AlertTriangle className="h-4 w-4" />
Erreurs ({details.length})
</h3>
<div className="space-y-1 max-h-40 overflow-y-auto">
{details.map((d: string | { message?: string; file?: string }, i: number) => (
<div key={i} className="text-xs bg-red-50 border border-red-100 rounded px-3 py-1.5 text-red-700">
{typeof d === "string" ? d : `${d.file ? `[${d.file}] ` : ""}${d.message || ""}`}
</div>
))}
</div>
</div>
);
}
} catch { /* JSON invalide */ }
return null;
})()}
</div>
)}
</DialogContent>
</Dialog>
<Dialog open={detailDialogOpen} onOpenChange={setDetailDialogOpen}><DialogContent className="max-h-[80vh] max-w-2xl overflow-y-auto"><DialogHeader><DialogTitle className="flex items-center gap-2"><FileText className="h-5 w-5 text-blue-500" />Détails de l’import</DialogTitle></DialogHeader>
{selectedLog && <div className="space-y-4"><div className="grid gap-3 sm:grid-cols-2"><div className="rounded-lg bg-gray-50 p-3"><p className="mb-1 text-xs font-medium text-gray-500">Fichier</p><p className="break-all text-sm font-semibold">{selectedLog.fileName}</p></div><div className="rounded-lg bg-gray-50 p-3"><p className="mb-1 text-xs font-medium text-gray-500">Date et heure d’import</p><p className="text-sm font-semibold">{new Date(selectedLog.importedAt).toLocaleString("fr-FR")}</p></div><div className="rounded-lg bg-gray-50 p-3"><p className="mb-1 text-xs font-medium text-gray-500">Compte responsable</p><p className="text-sm font-semibold">{selectedLog.userName?.trim() || selectedLog.userEmail || "Compte supprimé"}</p>{selectedLog.userName && selectedLog.userEmail && <p className="mt-0.5 text-xs text-gray-500">{selectedLog.userEmail}</p>}</div><div className="rounded-lg bg-gray-50 p-3"><p className="mb-1 text-xs font-medium text-gray-500">Origine</p><div className="flex items-center gap-2"><SourceBadge source={selectedLog.importSource} /><TriggerBadge trigger={selectedLog.importTrigger} /></div></div><div className="rounded-lg bg-gray-50 p-3"><p className="mb-1 text-xs font-medium text-gray-500">Fichier source</p><p className="text-sm font-semibold">ID {selectedLog.sourceFileId} · {selectedLog.sourceStatus || "indisponible"}</p>{selectedLog.sourceCreatedAt && <p className="mt-0.5 text-xs text-gray-500">Créé le {new Date(selectedLog.sourceCreatedAt).toLocaleString("fr-FR")}</p>}</div></div>
<div className="grid grid-cols-4 gap-2"><div className="rounded-lg bg-gray-50 p-3 text-center"><p className="text-2xl font-bold text-gray-700">{selectedLog.totalInvoicesDetected}</p><p className="mt-1 text-xs text-gray-500">Détectées</p></div><div className="rounded-lg bg-green-50 p-3 text-center"><p className="text-2xl font-bold text-green-700">{selectedLog.invoicesImported}</p><p className="mt-1 text-xs text-green-600">Importées</p></div><div className="rounded-lg bg-yellow-50 p-3 text-center"><p className="text-2xl font-bold text-yellow-700">{selectedLog.duplicatesIgnored}</p><p className="mt-1 text-xs text-yellow-600">Doublons</p></div><div className="rounded-lg bg-red-50 p-3 text-center"><p className="text-2xl font-bold text-red-600">{selectedLog.errors}</p><p className="mt-1 text-xs text-red-600">Erreurs</p></div></div>
{selectedLog.warningMessage && <div className="rounded-lg border border-amber-200 bg-amber-50 p-3 text-sm text-amber-800"><strong>Avertissement :</strong> {selectedLog.warningMessage}</div>}
{selectedLog.duplicateDetails && <DetailsList title="Doublons ignorés" tone="yellow" value={selectedLog.duplicateDetails} icon={<RotateCcw className="h-4 w-4" />} />}
{selectedLog.errorDetails && <DetailsList title="Erreurs" tone="red" value={selectedLog.errorDetails} icon={<AlertTriangle className="h-4 w-4" />} />}
</div>}
</DialogContent></Dialog>
</DashboardLayout>
);
}
function DetailsList({ title, tone, value, icon }: { title: string; tone: "yellow" | "red"; value: string; icon: React.ReactNode }) {
try {
const details = JSON.parse(value);
if (!Array.isArray(details) || details.length === 0) return null;
const styles = tone === "yellow" ? "border-yellow-100 bg-yellow-50 text-yellow-800" : "border-red-100 bg-red-50 text-red-700";
const heading = tone === "yellow" ? "text-yellow-700" : "text-red-600";
return <div><h3 className={`mb-2 flex items-center gap-1 text-sm font-semibold ${heading}`}>{icon}{title} ({details.length})</h3><div className="max-h-40 space-y-1 overflow-y-auto">{details.map((detail: string | Record<string, unknown>, index: number) => <div key={index} className={`rounded border px-3 py-1.5 text-xs ${styles}`}>{typeof detail === "string" ? detail : Object.values(detail).filter(Boolean).join(" — ")}</div>)}</div></div>;
} catch {
return null;
}
}

View File

@@ -41,6 +41,8 @@ type ErrorDetail = {
value?: string;
};
const IMPORT_LOG_PAGE_SIZE = 200;
function parseJson<T>(str: string | null | undefined): T[] {
if (!str) return [];
try {
@@ -59,7 +61,13 @@ function StatusBadge({ imported, duplicates, errors }: { imported: number; dupli
}
export default function ImportReport() {
const { data: logs, isLoading } = trpc.importLogs.getByUser.useQuery();
const [page, setPage] = useState(0);
const { data: logsPage, isLoading } = trpc.importLogs.getByUser.useQuery({
limit: IMPORT_LOG_PAGE_SIZE,
offset: page * IMPORT_LOG_PAGE_SIZE,
});
const logs = (logsPage?.items ?? []) as ImportLog[];
const totalLogs = logsPage?.total ?? 0;
const [yearFilter, setYearFilter] = useState<string>(String(new Date().getFullYear()));
const [monthFilter, setMonthFilter] = useState<string>("all");
const [statusFilter, setStatusFilter] = useState<string>("all");
@@ -319,6 +327,14 @@ export default function ImportReport() {
)}
</div>
</div>
{totalLogs > IMPORT_LOG_PAGE_SIZE && (
<div className="mt-3 flex items-center justify-end gap-2 text-xs text-gray-500">
<span>{totalLogs.toLocaleString("fr-FR")} journaux</span>
<Button size="sm" variant="outline" className="h-8" disabled={page === 0} onClick={() => setPage((current) => current - 1)}>Précédent</Button>
<span>Page {page + 1}</span>
<Button size="sm" variant="outline" className="h-8" disabled={(page + 1) * IMPORT_LOG_PAGE_SIZE >= totalLogs} onClick={() => setPage((current) => current + 1)}>Suivant</Button>
</div>
)}
</CardContent>
</Card>

View File

@@ -1,5 +1,6 @@
import { useState, useEffect } from "react";
import { useState, useEffect, useMemo } from "react";
import { trpc } from "@/lib/trpc";
import AzureAdExportSettingsCard from "@/components/AzureAdExportSettingsCard";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
@@ -7,6 +8,7 @@ import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import { Badge } from "@/components/ui/badge";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { useAuth } from "@/_core/hooks/useAuth";
import { toast } from "sonner";
import {
Loader2, Save, Upload, FolderOpen, Mail, Play, Square, Download,
@@ -152,6 +154,8 @@ function BackupSection() {
export default function ImportSettings() {
const { user } = useAuth();
const isAdmin = user?.role === "admin";
// Chargement différé : on ne charge les statuts de services qu'après le montage
const { data: settings, isLoading } = trpc.importSettings.get.useQuery(undefined, {
staleTime: 30_000,
@@ -172,6 +176,18 @@ export default function ImportSettings() {
const testAzureConnectionMutation = trpc.importSettings.testAzureConnection.useMutation();
const testSharePointUploadMutation = trpc.importSettings.testSharePointUpload.useMutation();
const testEmailConnectionMutation = trpc.importSettings.testEmailConnection.useMutation();
const { data: emailAccounts, isLoading: emailAccountsLoading } = trpc.emailImportService.getConfiguredAccounts.useQuery(undefined, {
enabled: isAdmin,
staleTime: 30_000,
});
const [selectedEmailAccountId, setSelectedEmailAccountId] = useState("");
const selectedEmailAccountUserId = Number(selectedEmailAccountId);
const { data: selectedEmailAccountStatus } = trpc.emailImportService.accountStatus.useQuery(
{ userId: selectedEmailAccountUserId },
{ enabled: isAdmin && Number.isInteger(selectedEmailAccountUserId) && selectedEmailAccountUserId > 0 },
);
const startEmailAccountMutation = trpc.emailImportService.startAccount.useMutation();
const stopEmailAccountMutation = trpc.emailImportService.stopAccount.useMutation();
// Manual import
const [manualImportEnabled, setManualImportEnabled] = useState(true);
@@ -256,6 +272,22 @@ export default function ImportSettings() {
}
}, [settings]);
const configuredEmailAccounts = useMemo(
() => (emailAccounts ?? []).filter((account) => account.isConfigured === 1),
[emailAccounts],
);
useEffect(() => {
if (!isAdmin || configuredEmailAccounts.length === 0) {
setSelectedEmailAccountId("");
return;
}
if (!configuredEmailAccounts.some((account) => String(account.userId) === selectedEmailAccountId)) {
setSelectedEmailAccountId(String(configuredEmailAccounts[0].userId));
}
}, [isAdmin, configuredEmailAccounts, selectedEmailAccountId]);
const computedExportMode = (): "browser" | "folder" | "both" => {
if (bapExportBrowser && bapExportFolder) return "both";
if (bapExportFolder) return "folder";
@@ -333,25 +365,21 @@ export default function ImportSettings() {
</div>
<div>
<h1 className="text-3xl font-bold bg-gradient-to-r from-blue-600 to-cyan-600 bg-clip-text text-transparent">
Paramètres import / export
Paramètres
</h1>
<p className="text-muted-foreground mt-0.5 text-sm">
Configurez les méthodes d'importation et les options d'export des factures BAP
Configurez les méthodes d’importation et la sauvegarde de la base de données
</p>
</div>
</div>
{/* Onglets Import / Export */}
{/* Les destinations d’export sont administrées dans Automatismes d’export. */}
<Tabs defaultValue="import" className="w-full">
<TabsList className="grid w-full grid-cols-3 h-12 mb-6">
<TabsList className="grid w-full grid-cols-2 h-12 mb-6">
<TabsTrigger value="import" className="flex items-center gap-2 text-base">
<ArrowDownToLine className="w-4 h-4" />
<Inbox className="w-4 h-4" />
Paramètres d'import
</TabsTrigger>
<TabsTrigger value="export" className="flex items-center gap-2 text-base">
<Share2 className="w-4 h-4" />
Paramètres d'export
</TabsTrigger>
<TabsTrigger value="backup" className="flex items-center gap-2 text-base">
<DatabaseBackup className="w-4 h-4" />
Sauvegarde DB
@@ -361,6 +389,8 @@ export default function ImportSettings() {
{/* ===== ONGLET IMPORT ===== */}
<TabsContent value="import" className="space-y-6">
<AzureAdExportSettingsCard />
{/* Import manuel */}
<Card className="border-2 hover:border-primary/50 transition-colors">
<CardHeader className="bg-gradient-to-r from-purple-50 to-pink-50 dark:from-purple-950/20 dark:to-pink-950/20 border-b">
@@ -538,8 +568,9 @@ export default function ImportSettings() {
/>
</div>
{emailImportEnabled && (
<div className="space-y-4 p-4 bg-green-50/50 dark:bg-green-950/10 rounded-lg border-2 border-green-200 dark:border-green-800">
<div className="space-y-4 p-4 bg-green-50/50 dark:bg-green-950/10 rounded-lg border-2 border-green-200 dark:border-green-800">
{emailImportEnabled && (
<>
{/* Mode d'authentification */}
<div className="space-y-2">
<Label className="text-base font-medium">Mode d'authentification IMAP</Label>
@@ -569,7 +600,7 @@ export default function ImportSettings() {
</div>
{emailImportAuthMode === "oauth2" && (
<div className="p-3 bg-purple-50 dark:bg-purple-950/20 rounded-lg border border-purple-200 dark:border-purple-800 text-sm text-purple-700 dark:text-purple-300">
<strong>Office 365 :</strong> Microsoft a désactivé l'auth basique pour Exchange Online. Utilisez ce mode avec les credentials Azure AD déjà configurés dans l'onglet Export.
<strong>Office 365 :</strong> Microsoft a désactivé l'auth basique pour Exchange Online. Utilisez ce mode avec les paramètres Azure AD configurés dans la carte Microsoft Azure AD au début de cette page.
</div>
)}
</div>
@@ -601,7 +632,7 @@ export default function ImportSettings() {
{emailImportAuthMode === "oauth2" && (
<div className="p-3 bg-muted/30 rounded-lg border text-sm text-muted-foreground">
<Wifi className="inline h-4 w-4 mr-1" />
En mode OAuth2, les credentials Azure AD (Tenant ID, Client ID, Client Secret) configurés dans l'onglet <strong>Export &gt; SharePoint</strong> seront utilisés automatiquement.
En mode OAuth2, les paramètres Azure AD (Tenant ID, Client ID, Client Secret) configurés dans la <strong>carte Microsoft Azure AD</strong> seront utilisés automatiquement.
</div>
)}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
@@ -742,12 +773,12 @@ export default function ImportSettings() {
const result = await startEmailServiceMutation.mutateAsync();
if (result.success) {
await utils.emailImportService.status.invalidate();
toast.success("Service démarré");
toast.success(result.message);
} else {
toast.error("Impossible de démarrer le service");
toast.error(result.message || "Impossible de démarrer le service", { duration: 8000 });
}
} catch {
toast.error("Impossible de démarrer le service");
} catch (error: any) {
toast.error(error?.message || "Impossible de démarrer le service", { duration: 8000 });
}
}}
disabled={!emailImportEnabled || startEmailServiceMutation.isPending}
@@ -758,8 +789,82 @@ export default function ImportSettings() {
</Button>
)}
</div>
<p className="text-xs text-muted-foreground">
Enregistrez les paramètres avant de démarrer. Le service ne démarre que si le test IMAP est concluant.
</p>
</>
)}
{isAdmin && (
<div className="space-y-3 border-t border-green-200 pt-4 dark:border-green-800">
<div>
<p className="text-sm font-medium">Compte source administré</p>
<p className="text-xs text-muted-foreground">
Démarrez la boîte déjà configurée, même si ses paramètres appartiennent à un autre compte applicatif. Aucun identifiant n’est affiché ni recopié.
</p>
</div>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
<select
aria-label="Compte source de réception e-mail"
className="h-10 min-w-0 flex-1 rounded-md border border-input bg-background px-3 text-sm"
value={selectedEmailAccountId}
onChange={(event) => setSelectedEmailAccountId(event.target.value)}
disabled={emailAccountsLoading || configuredEmailAccounts.length === 0}
>
{configuredEmailAccounts.length === 0 ? (
<option value="">Aucune boîte e-mail configurée</option>
) : (
configuredEmailAccounts.map((account) => (
<option key={account.userId} value={String(account.userId)}>
{account.userName || account.userEmail || `Compte #${account.userId}`}
{account.automaticEnabled === 1 ? " — réception activée" : " — réception désactivée"}
</option>
))
)}
</select>
{selectedEmailAccountStatus?.isRunning ? (
<Button
variant="destructive"
onClick={async () => {
try {
const result = await stopEmailAccountMutation.mutateAsync({ userId: selectedEmailAccountUserId });
await utils.emailImportService.accountStatus.invalidate();
toast.success(result.message);
} catch (error: any) {
toast.error(error?.message || "Impossible d’arrêter le service.");
}
}}
disabled={stopEmailAccountMutation.isPending}
>
{stopEmailAccountMutation.isPending ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <Square className="mr-2 h-4 w-4" />}
Arrêter
</Button>
) : (
<Button
onClick={async () => {
try {
const result = await startEmailAccountMutation.mutateAsync({ userId: selectedEmailAccountUserId });
if (result.success) {
await utils.emailImportService.accountStatus.invalidate();
toast.success(result.message);
} else {
toast.error(result.message, { duration: 8000 });
}
} catch (error: any) {
toast.error(error?.message || "Impossible de démarrer le service.", { duration: 8000 });
}
}}
disabled={!selectedEmailAccountUserId || startEmailAccountMutation.isPending}
className="bg-green-600 hover:bg-green-700"
>
{startEmailAccountMutation.isPending ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <Play className="mr-2 h-4 w-4" />}
Démarrer
</Button>
)}
</div>
</div>
)}
</div>
)}
</CardContent>
</Card>

View File

@@ -1,5 +1,5 @@
import { useState, useEffect } from "react";
import { useParams, useLocation } from "wouter";
import { useParams, useLocation, useSearch } from "wouter";
import DashboardLayout from "@/components/DashboardLayout";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
@@ -9,10 +9,13 @@ import { Badge } from "@/components/ui/badge";
import { trpc } from "@/lib/trpc";
import { ArrowLeft, Save, FileText } from "lucide-react";
import { toast } from "sonner";
import { getBapReturnLocation } from "@/lib/bapNavigation";
export default function InvoiceDetail() {
const { id } = useParams<{ id: string }>();
const [, setLocation] = useLocation();
const detailSearch = useSearch();
const returnLocation = getBapReturnLocation(detailSearch);
const invoiceId = parseInt(id || "0");
const { data: invoice, isLoading } = trpc.invoices.getById.useQuery({ id: invoiceId });
@@ -69,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) {
@@ -172,7 +162,7 @@ export default function InvoiceDetail() {
<FileText className="w-16 h-16 text-gray-300 mb-4" />
<h2 className="text-2xl font-bold mb-2">Facture introuvable</h2>
<p className="text-gray-500 mb-4">Cette facture n'existe pas ou a été supprimée</p>
<Button onClick={() => setLocation("/invoices")}>
<Button onClick={() => setLocation(returnLocation)}>
<ArrowLeft className="w-4 h-4 mr-2" />
Retour aux factures
</Button>
@@ -187,7 +177,7 @@ export default function InvoiceDetail() {
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<Button variant="ghost" onClick={() => setLocation("/invoices")}>
<Button variant="ghost" onClick={() => setLocation(returnLocation)}>
<ArrowLeft className="w-4 h-4 mr-2" />
Retour
</Button>

View File

@@ -1,4 +1,4 @@
import { useState } from "react";
import { useMemo, useState } from "react";
import DashboardLayout from "@/components/DashboardLayout";
import { useAuth } from "@/_core/hooks/useAuth";
import { Card, CardContent } from "@/components/ui/card";
@@ -34,17 +34,29 @@ import {
} from "@/components/ui/table";
import { trpc } from "@/lib/trpc";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Search, FileText, Download, FileSpreadsheet, Trash2, Edit, Trash, Filter, LayoutList, LayoutGrid, ArrowUpDown, ArrowUp, ArrowDown } from "lucide-react";
import { Search, FileText, Download, FileSpreadsheet, Trash2, Edit, Trash, Filter, LayoutList, LayoutGrid, ArrowUpDown, ArrowUp, ArrowDown, CalendarDays } from "lucide-react";
import * as XLSX from 'xlsx';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import { toast } from "sonner";
import { useLocation } from "wouter";
import { matchesInvoicePeriod } from "@shared/invoicePeriod";
import { requiresBapBeforeOriginalPdfExport } from "@shared/bapExportGuard";
type SortField = "invoiceDate" | "createdAt";
type SortDir = "asc" | "desc";
export default function Invoices() {
type InvoiceScope = "all" | "subscriptions";
const MONTHS = [
["01", "Janvier"], ["02", "Février"], ["03", "Mars"], ["04", "Avril"],
["05", "Mai"], ["06", "Juin"], ["07", "Août"], ["08", "Septembre"],
["09", "Septembre"], ["10", "Octobre"], ["11", "Novembre"], ["12", "Décembre"],
] as const;
export default function Invoices({ scope = "all" }: { scope?: InvoiceScope }) {
const [, setLocation] = useLocation();
const currentYear = new Date().getFullYear();
const isSubscriptionPage = scope === "subscriptions";
const [searchQuery, setSearchQuery] = useState("");
const [compactMode, setCompactMode] = useState(true);
const [sortField, setSortField] = useState<SortField>("createdAt");
@@ -52,9 +64,11 @@ export default function Invoices() {
const [selectedIds, setSelectedIds] = useState<number[]>([]);
const [statusFilter, setStatusFilter] = useState<string>("all");
const [recipientFilter, setRecipientFilter] = useState<string>("all");
const [subscriptionFilter, setSubscriptionFilter] = useState<string>("all"); // all | yes | no
const [subscriptionFilter, setSubscriptionFilter] = useState<string>(isSubscriptionPage ? "yes" : "all"); // all | yes | no
const [entityFilter, setEntityFilter] = useState<string>("all"); // all | santinova | itinova
const [ventilationFilter, setVentilationFilter] = useState<string>("all");
const [selectedYear, setSelectedYear] = useState<string>(String(currentYear));
const [selectedMonth, setSelectedMonth] = useState<string>("all");
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [invoiceToDelete, setInvoiceToDelete] = useState<number | null>(null);
const [addDialogOpen, setAddDialogOpen] = useState(false);
@@ -194,17 +208,20 @@ export default function Invoices() {
};
// Collect unique recipients for the filter dropdown
const uniqueRecipients = Array.from(
new Set(
(invoices || []).map(inv => (inv as any).recipientName).filter(Boolean)
)
).sort();
const scopedInvoices = useMemo(
() => (invoices || []).filter(inv => !isSubscriptionPage || inv.isSubscription === 1),
[invoices, isSubscriptionPage],
);
const uniqueVentilations = Array.from(
new Set(
(invoices || []).map(inv => (inv as any).ventilationComptable).filter(Boolean)
)
).sort();
const uniqueRecipients = useMemo(
() => Array.from(new Set(scopedInvoices.map(inv => (inv as any).recipientName).filter(Boolean))).sort(),
[scopedInvoices],
);
const uniqueVentilations = useMemo(
() => Array.from(new Set(scopedInvoices.map(inv => (inv as any).ventilationComptable).filter(Boolean))).sort(),
[scopedInvoices],
);
const handleSort = (field: SortField) => {
if (sortField === field) {
@@ -220,7 +237,7 @@ export default function Invoices() {
return sortDir === "asc" ? <ArrowUp className="w-3 h-3 ml-1" /> : <ArrowDown className="w-3 h-3 ml-1" />;
};
const filteredInvoices = invoices?.filter((inv) => {
const filteredInvoices = scopedInvoices.filter((inv) => {
// Filter by search query
if (searchQuery) {
const query = searchQuery.toLowerCase();
@@ -230,6 +247,9 @@ export default function Invoices() {
if (!matchesSearch) return false;
}
// Même convention que Factures BAP : date de facture, sinon réception.
if (!matchesInvoicePeriod(inv, selectedYear, selectedMonth)) return false;
// Filter by export status
if (statusFilter !== "all") {
if (statusFilter === "exported" && inv.exportStatus !== "exported") return false;
@@ -268,22 +288,17 @@ export default function Invoices() {
return true;
});
const invoicesInPeriod = useMemo(
() => scopedInvoices.filter(inv => matchesInvoicePeriod(inv, selectedYear, selectedMonth)),
[scopedInvoices, selectedYear, selectedMonth],
);
const statusCounts = {
all: invoices?.length || 0,
exported: invoices?.filter(inv => inv.exportStatus === "exported").length || 0,
not_exported: invoices?.filter(inv => inv.exportStatus === "not_exported").length || 0,
export_error: invoices?.filter(inv => inv.exportStatus === "export_error").length || 0,
all: invoicesInPeriod.length,
exported: invoicesInPeriod.filter(inv => inv.exportStatus === "exported").length,
not_exported: invoicesInPeriod.filter(inv => inv.exportStatus === "not_exported").length,
export_error: invoicesInPeriod.filter(inv => inv.exportStatus === "export_error").length,
};
// Old filter logic (to be removed)
const _oldFilteredInvoices = invoices?.filter((inv) => {
if (!searchQuery) return true;
const query = searchQuery.toLowerCase();
return (
inv.supplierName?.toLowerCase().includes(query) ||
inv.invoiceNumber?.toLowerCase().includes(query)
);
});
const handleSelectAll = (checked: boolean) => {
if (checked) {
@@ -344,10 +359,14 @@ export default function Invoices() {
const isEligibleForExport = (invoice: any) => {
return (invoice.qualityScore || 0) === 100;
};
const isEligibleForPdfExport = (invoice: any) => {
return isEligibleForExport(invoice) && !requiresBapBeforeOriginalPdfExport(invoice);
};
const hasOnlyEligibleSelected = selectedIds.length > 0 && selectedIds.every(id => {
const invoice = invoices?.find(inv => inv.id === id);
return invoice && isEligibleForExport(invoice);
return invoice && isEligibleForPdfExport(invoice);
});
const sortedInvoices = [...(filteredInvoices || [])].sort((a, b) => {
@@ -370,8 +389,10 @@ export default function Invoices() {
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold">Factures</h1>
<p className="text-gray-500 mt-1">Gérez toutes vos factures importées</p>
<h1 className="text-3xl font-bold">{isSubscriptionPage ? "Factures abonnements" : "Factures"}</h1>
<p className="text-gray-500 mt-1">
{isSubscriptionPage ? "Gérez les factures identifiées comme abonnements" : "Gérez toutes vos factures importées"}
</p>
</div>
<div className="flex gap-2">
<Button
@@ -387,7 +408,7 @@ export default function Invoices() {
onClick={handleExport}
disabled={selectedIds.length === 0 || !hasOnlyEligibleSelected || exportMutation.isPending}
className="bg-blue-600 hover:bg-blue-700"
title={!hasOnlyEligibleSelected && selectedIds.length > 0 ? "Seules les factures avec un score de 100% peuvent être exportées" : ""}
title={!hasOnlyEligibleSelected && selectedIds.length > 0 ? "Les factures hors abonnement doivent être validées BAP avant l'export PDF" : ""}
>
<Download className="w-4 h-4 mr-2" />
PDF ({selectedIds.length})
@@ -456,9 +477,30 @@ export default function Invoices() {
</div>
</div>
{/* Ligne 2 : Filtres destinataire, abonnement, entité, ventilation */}
{/* Ligne 2 : Filtres de période, destinataire, abonnement, entité, ventilation */}
<div className="flex gap-2 items-center flex-wrap mb-3">
<Filter className="w-4 h-4 text-blue-400 shrink-0" />
<div className="flex items-center gap-1.5 text-sm font-medium text-blue-600">
<CalendarDays className="w-4 h-4" />
Période :
</div>
<Select value={selectedYear} onValueChange={(value) => { setSelectedYear(value); if (value === "all") setSelectedMonth("all"); }}>
<SelectTrigger className="w-28 h-8 bg-white border-blue-200 text-sm"><SelectValue placeholder="Année" /></SelectTrigger>
<SelectContent>
<SelectItem value="all">Toute année</SelectItem>
{Array.from({ length: 5 }, (_, index) => currentYear - index).map(year => <SelectItem key={year} value={String(year)}>{year}</SelectItem>)}
</SelectContent>
</Select>
<Select value={selectedMonth} onValueChange={setSelectedMonth} disabled={selectedYear === "all"}>
<SelectTrigger className="w-36 h-8 bg-white border-blue-200 text-sm"><SelectValue placeholder="Mois" /></SelectTrigger>
<SelectContent>
<SelectItem value="all">Tous les mois</SelectItem>
{MONTHS.map(([value, label]) => <SelectItem key={value} value={value}>{label}</SelectItem>)}
</SelectContent>
</Select>
{(selectedYear !== String(currentYear) || selectedMonth !== "all") && (
<button onClick={() => { setSelectedYear(String(currentYear)); setSelectedMonth("all"); }} className="text-xs text-blue-600 hover:text-blue-800 underline">Réinitialiser</button>
)}
<Select value={recipientFilter} onValueChange={setRecipientFilter}>
<SelectTrigger className="w-[180px] bg-white border-blue-200 text-sm">
<SelectValue placeholder="Destinataire" />
@@ -471,7 +513,7 @@ export default function Invoices() {
))}
</SelectContent>
</Select>
<Select value={subscriptionFilter} onValueChange={setSubscriptionFilter}>
<Select value={isSubscriptionPage ? "yes" : subscriptionFilter} onValueChange={setSubscriptionFilter} disabled={isSubscriptionPage}>
<SelectTrigger className="w-[170px] bg-white border-blue-200 text-sm">
<SelectValue placeholder="Abonnement" />
</SelectTrigger>

View File

@@ -32,6 +32,8 @@ import {
TableRow,
} from "@/components/ui/table";
import { trpc } from "@/lib/trpc";
import { BAP_MIN_QUALITY_SCORE, meetsBapQualityThreshold } from "@shared/bapEligibility";
import { wasExportedBeforeBap } from "@shared/bapExportGuard";
import { Search, FileText, Download, FileSpreadsheet, Trash2, Edit, Trash, CheckCircle, CheckCircle2, ShieldCheck, RefreshCw, FolderDown, ChevronDown, ChevronRight, ChevronsUpDown, CalendarDays, ArrowUpDown, ArrowUp, ArrowDown } from "lucide-react";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
@@ -87,7 +89,8 @@ function downloadBapPdf(
}
import * as XLSX from 'xlsx';
import { toast } from "sonner";
import { useLocation } from "wouter";
import { useLocation, useSearch } from "wouter";
import { buildBapDetailLocation, parseBapFilters } from "@/lib/bapNavigation";
// Helper function to determine field color based on origin
const getFieldColor = (invoice: any, fieldName: string): string => {
@@ -108,9 +111,15 @@ const getFieldColor = (invoice: any, fieldName: string): string => {
export default function InvoicesBAP() {
const [, setLocation] = useLocation();
const [searchQuery, setSearchQuery] = useState("");
const queryString = useSearch();
const currentYear = new Date().getFullYear();
const initialFilters = parseBapFilters(queryString, String(currentYear));
const [searchQuery, setSearchQuery] = useState(initialFilters.searchQuery);
const [selectedIds, setSelectedIds] = useState<number[]>([]);
const [statusFilter, setStatusFilter] = useState<string>("all");
const [statusFilter, setStatusFilter] = useState<string>(initialFilters.statusFilter);
const [recipientFilter, setRecipientFilter] = useState<string>(initialFilters.recipientFilter);
const [entityFilter, setEntityFilter] = useState<string>(initialFilters.entityFilter);
const [ventilationFilter, setVentilationFilter] = useState<string>(initialFilters.ventilationFilter);
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [invoiceToDelete, setInvoiceToDelete] = useState<number | null>(null);
const [addDialogOpen, setAddDialogOpen] = useState(false);
@@ -126,12 +135,25 @@ export default function InvoicesBAP() {
const [expandedIds, setExpandedIds] = useState<Set<number>>(new Set());
const [allExpanded, setAllExpanded] = useState(false);
// Filtre par période
const currentYear = new Date().getFullYear();
const [selectedYear, setSelectedYear] = useState<string>(String(currentYear));
const [selectedMonth, setSelectedMonth] = useState<string>("all"); // "all" ou "01".."12"
const [selectedYear, setSelectedYear] = useState<string>(initialFilters.selectedYear);
const [selectedMonth, setSelectedMonth] = useState<string>(initialFilters.selectedMonth); // "all" ou "01".."12"
// Tri
const [sortField, setSortField] = useState<"invoiceDate" | "createdAt">("createdAt");
const [sortDir, setSortDir] = useState<"asc" | "desc">("desc");
const [sortField, setSortField] = useState<"invoiceDate" | "createdAt">(initialFilters.sortField);
const [sortDir, setSortDir] = useState<"asc" | "desc">(initialFilters.sortDir);
const openInvoiceDetail = (invoiceId: number) => {
setLocation(buildBapDetailLocation(invoiceId, {
searchQuery,
statusFilter,
selectedYear,
selectedMonth,
recipientFilter,
entityFilter,
ventilationFilter,
sortField,
sortDir,
}));
};
const toggleRow = (id: number) => {
setExpandedIds(prev => {
@@ -154,6 +176,14 @@ export default function InvoicesBAP() {
const { data: allInvoices, isLoading } = trpc.invoices.list.useQuery();
// Filter for BAP invoices only (Abonnement = NON, isSubscription = 0)
const invoices = allInvoices?.filter(inv => inv.isSubscription === 0);
const uniqueRecipients = useMemo(
() => Array.from(new Set((invoices || []).map(inv => (inv as any).recipientName).filter(Boolean))).sort(),
[invoices],
);
const uniqueVentilations = useMemo(
() => Array.from(new Set((invoices || []).map(inv => (inv as any).ventilationComptable).filter(Boolean))).sort(),
[invoices],
);
// IDs des factures déjà validées BAP (pour charger leurs pdfUrl depuis bapHistory)
// Stabilisé avec useMemo pour éviter les re-renders infinis (anti-pattern tRPC)
const validatedInvoiceIds = useMemo(
@@ -380,8 +410,7 @@ export default function InvoicesBAP() {
// Déclarée ici (avant filteredInvoices et statusCounts) pour éviter le hoisting error
const isEligibleForBAPValidation = (invoice: any) => {
return (
(invoice.qualityScore || 0) === 100 &&
invoice.exportStatus !== "exported" &&
meetsBapQualityThreshold(invoice.qualityScore) &&
invoice.isSubscription === 0 &&
invoice.serviceConcerne &&
invoice.typeAchat &&
@@ -425,6 +454,27 @@ export default function InvoicesBAP() {
if (statusFilter === "bap_pending" && inv.bapValidated === 1) return false;
if (statusFilter === "to_complete" && !isToComplete(inv)) return false;
}
// Même convention que la page Factures : valeur vide et entités déduites du service.
if (recipientFilter !== "all") {
if (recipientFilter === "__empty__") {
if ((inv as any).recipientName) return false;
} else if ((inv as any).recipientName !== recipientFilter) {
return false;
}
}
if (entityFilter !== "all") {
const service = ((inv as any).serviceConcerne || "").trim().toUpperCase();
if (entityFilter === "santinova" && service !== "DSI SANTINOVA") return false;
if (entityFilter === "itinova" && service === "DSI SANTINOVA") return false;
}
if (ventilationFilter !== "all") {
if (ventilationFilter === "__empty__") {
if ((inv as any).ventilationComptable) return false;
} else if ((inv as any).ventilationComptable !== ventilationFilter) {
return false;
}
}
return true;
});
@@ -497,7 +547,10 @@ export default function InvoicesBAP() {
exportExcelMutation.mutate({ invoiceIds: selectedIds });
};
const getExportStatusBadge = (status: string) => {
const getExportStatusBadge = (status: string, invoice?: any) => {
if (invoice && wasExportedBeforeBap(invoice)) {
return <Badge className="bg-amber-100 text-amber-800 hover:bg-amber-100">Exporté avant BAP</Badge>;
}
switch (status) {
case "exported":
return <Badge className="bg-green-100 text-green-800 hover:bg-green-100">Exporté</Badge>;
@@ -512,19 +565,19 @@ export default function InvoicesBAP() {
const getQualityBadge = (score: number | null) => {
if (score === null) return <Badge variant="outline">-</Badge>;
if (score === 100) return <Badge className="bg-green-100 text-green-800 hover:bg-green-100">{score}%</Badge>;
if (meetsBapQualityThreshold(score)) return <Badge className="bg-green-100 text-green-800 hover:bg-green-100">{score}%</Badge>;
if (score >= 80) return <Badge className="bg-yellow-100 text-yellow-800 hover:bg-yellow-100">{score}%</Badge>;
return <Badge className="bg-red-100 text-red-800 hover:bg-red-100">{score}%</Badge>;
};
const isEligibleForExport = (invoice: any) => {
// Une facture BAP est exportable si :
// 1. Score = 100%
// 1. Score >= 90 % (seuil BAP)
// 2. Type d'achat rempli
// 3. Service concerné rempli
// 4. Ventilation comptable remplie
return (
(invoice.qualityScore || 0) === 100 &&
meetsBapQualityThreshold(invoice.qualityScore) &&
invoice.typeAchat &&
invoice.serviceConcerne &&
invoice.ventilationComptable
@@ -535,8 +588,7 @@ export default function InvoicesBAP() {
const getBAPValidationTooltip = (invoice: any): string => {
const reasons: string[] = [];
if ((invoice.qualityScore || 0) < 100) reasons.push("Score < 100%");
if (invoice.exportStatus === "exported") reasons.push("Déjà exportée");
if (!meetsBapQualityThreshold(invoice.qualityScore)) reasons.push(`Score < ${BAP_MIN_QUALITY_SCORE}%`);
if (invoice.isSubscription !== 0) reasons.push("Marquée comme abonnement");
if (!invoice.serviceConcerne) reasons.push("Service manquant");
if (!invoice.typeAchat) reasons.push("Type d'achat manquant");
@@ -641,7 +693,7 @@ export default function InvoicesBAP() {
</Button>
<Button
onClick={() => {
if (confirm("Valider en BAP toutes les factures éligibles (score 100%, champs remplis, non abonnement) ? Les PDFs annotés seront générés automatiquement.")) {
if (confirm(`Valider en BAP toutes les factures éligibles (score ≥ ${BAP_MIN_QUALITY_SCORE}%, champs remplis, non abonnement) ? Les PDFs annotés seront générés automatiquement.`)) {
validateBAPBulkMutation.mutate();
}
}}
@@ -763,6 +815,40 @@ export default function InvoicesBAP() {
))}
</SelectContent>
</Select>
<Select value={recipientFilter} onValueChange={setRecipientFilter}>
<SelectTrigger className="w-[180px] h-8 text-sm bg-white border-emerald-200">
<SelectValue placeholder="Destinataire" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Tous destinataires</SelectItem>
<SelectItem value="__empty__">Sans destinataire</SelectItem>
{uniqueRecipients.map((recipient) => (
<SelectItem key={recipient} value={recipient}>{recipient}</SelectItem>
))}
</SelectContent>
</Select>
<Select value={entityFilter} onValueChange={setEntityFilter}>
<SelectTrigger className="w-[150px] h-8 text-sm bg-white border-emerald-200">
<SelectValue placeholder="Entité" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Toutes entités</SelectItem>
<SelectItem value="santinova">SANTINOVA</SelectItem>
<SelectItem value="itinova">ITINOVA</SelectItem>
</SelectContent>
</Select>
<Select value={ventilationFilter} onValueChange={setVentilationFilter}>
<SelectTrigger className="w-[170px] h-8 text-sm bg-white border-emerald-200">
<SelectValue placeholder="Ventilation" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Toutes ventilations</SelectItem>
<SelectItem value="__empty__">Sans ventilation</SelectItem>
{uniqueVentilations.map((ventilation) => (
<SelectItem key={ventilation} value={ventilation}>{ventilation}</SelectItem>
))}
</SelectContent>
</Select>
{(selectedYear !== "all" || selectedMonth !== "all") && (
<button
onClick={() => { setSelectedYear(String(currentYear)); setSelectedMonth("all"); }}
@@ -926,7 +1012,7 @@ export default function InvoicesBAP() {
</TableCell>
<TableCell className="font-medium">
<button
onClick={() => setLocation(`/invoices/${invoice.id}`)}
onClick={() => openInvoiceDetail(invoice.id)}
className="text-blue-600 hover:text-blue-800 hover:underline"
>
{invoice.supplierName || "Inconnu"}
@@ -958,7 +1044,7 @@ export default function InvoicesBAP() {
)}
</div>
</TableCell>
<TableCell>{getExportStatusBadge(invoice.exportStatus || "not_exported")}</TableCell>
<TableCell>{getExportStatusBadge(invoice.exportStatus || "not_exported", invoice)}</TableCell>
<TableCell>
<div className="flex gap-1">
<Button
@@ -976,7 +1062,7 @@ export default function InvoicesBAP() {
<Button
size="sm"
variant="outline"
onClick={() => setLocation(`/invoices/${invoice.id}`)}
onClick={() => openInvoiceDetail(invoice.id)}
className="h-8 px-2"
title="Modifier"
>

View File

@@ -0,0 +1,90 @@
import DashboardLayout from "@/components/DashboardLayout";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { trpc } from "@/lib/trpc";
import { buildSupplierBudgetSummary } from "@shared/invoiceAnalytics";
import { CalendarDays, Euro, FileText } from "lucide-react";
import { useMemo, useState } from "react";
const MONTHS = [
["01", "Janvier"], ["02", "Février"], ["03", "Mars"], ["04", "Avril"],
["05", "Mai"], ["06", "Juin"], ["07", "Juillet"], ["08", "Août"],
["09", "Septembre"], ["10", "Octobre"], ["11", "Novembre"], ["12", "Décembre"],
] as const;
function formatCurrency(value: number) {
return new Intl.NumberFormat("fr-FR", { style: "currency", currency: "EUR" }).format(value);
}
export default function RealBudget() {
const currentYear = new Date().getFullYear();
const [selectedYear, setSelectedYear] = useState(String(currentYear));
const [selectedMonth, setSelectedMonth] = useState("all");
const { data: invoices, isLoading } = trpc.invoices.list.useQuery();
const availableYears = useMemo(() => {
const years = new Set<number>([currentYear]);
(invoices || []).forEach((invoice) => {
const value = invoice.invoiceDate ?? invoice.createdAt;
if (!value) return;
const date = new Date(value);
if (!Number.isNaN(date.getTime())) years.add(date.getFullYear());
});
return Array.from(years).sort((a, b) => b - a);
}, [invoices, currentYear]);
const rows = useMemo(
() => buildSupplierBudgetSummary(invoices || [], selectedYear, selectedMonth),
[invoices, selectedYear, selectedMonth],
);
const totals = useMemo(() => rows.reduce((total, row) => ({
subscriptionAmount: total.subscriptionAmount + row.subscriptionAmount,
nonSubscriptionAmount: total.nonSubscriptionAmount + row.nonSubscriptionAmount,
totalAmount: total.totalAmount + row.totalAmount,
}), { subscriptionAmount: 0, nonSubscriptionAmount: 0, totalAmount: 0 }), [rows]);
return (
<DashboardLayout>
<div className="space-y-6">
<div>
<h1 className="text-3xl font-bold">Budget réel</h1>
<p className="mt-1 text-muted-foreground">Montants réellement facturés, regroupés par fournisseur.</p>
</div>
<Card className="border-blue-100 bg-blue-50/70">
<CardContent className="flex flex-wrap items-center gap-3 py-4">
<div className="flex items-center gap-1.5 text-sm font-medium text-blue-700"><CalendarDays className="h-4 w-4" /> Période :</div>
<Select value={selectedYear} onValueChange={(value) => { setSelectedYear(value); if (value === "all") setSelectedMonth("all"); }}>
<SelectTrigger className="w-28 bg-white"><SelectValue placeholder="Année" /></SelectTrigger>
<SelectContent><SelectItem value="all">Toute année</SelectItem>{availableYears.map((year) => <SelectItem key={year} value={String(year)}>{year}</SelectItem>)}</SelectContent>
</Select>
<Select value={selectedMonth} onValueChange={setSelectedMonth} disabled={selectedYear === "all"}>
<SelectTrigger className="w-40 bg-white"><SelectValue placeholder="Mois" /></SelectTrigger>
<SelectContent><SelectItem value="all">Tous les mois</SelectItem>{MONTHS.map(([value, label]) => <SelectItem key={value} value={value}>{label}</SelectItem>)}</SelectContent>
</Select>
</CardContent>
</Card>
<div className="grid gap-4 md:grid-cols-3">
<Card><CardHeader className="pb-2"><CardTitle className="text-sm font-medium">Abonnements</CardTitle></CardHeader><CardContent><div className="text-2xl font-bold text-violet-700">{formatCurrency(totals.subscriptionAmount)}</div></CardContent></Card>
<Card><CardHeader className="pb-2"><CardTitle className="text-sm font-medium">Hors abonnement</CardTitle></CardHeader><CardContent><div className="text-2xl font-bold text-blue-700">{formatCurrency(totals.nonSubscriptionAmount)}</div></CardContent></Card>
<Card><CardHeader className="pb-2"><CardTitle className="flex items-center gap-2 text-sm font-medium"><Euro className="h-4 w-4" /> Total facturé</CardTitle></CardHeader><CardContent><div className="text-2xl font-bold text-emerald-700">{formatCurrency(totals.totalAmount)}</div></CardContent></Card>
</div>
<Card>
<CardHeader><CardTitle>Budget réel par fournisseur</CardTitle><CardDescription>Les factures finalisées sont séparées entre abonnements et hors abonnement.</CardDescription></CardHeader>
<CardContent>
{isLoading ? <div className="py-12 text-center text-muted-foreground">Chargement du budget…</div> : rows.length === 0 ? <div className="flex flex-col items-center gap-2 py-12 text-muted-foreground"><FileText className="h-10 w-10" />Aucune facture finalisée pour cette période.</div> : (
<div className="overflow-x-auto rounded-lg border">
<table className="w-full min-w-[850px] text-sm"><thead className="bg-muted/60 text-left text-muted-foreground"><tr><th className="px-4 py-3 font-medium">Fournisseur</th><th className="px-4 py-3 text-right font-medium">Abonnements</th><th className="px-4 py-3 text-right font-medium">Montant abonnements</th><th className="px-4 py-3 text-right font-medium">Hors abonnement</th><th className="px-4 py-3 text-right font-medium">Montant hors abonnement</th><th className="px-4 py-3 text-right font-medium">Total</th></tr></thead>
<tbody>{rows.map((row) => <tr key={row.supplierName} className="border-t hover:bg-muted/30"><td className="px-4 py-3 font-medium">{row.supplierName}</td><td className="px-4 py-3 text-right">{row.subscriptionCount}</td><td className="px-4 py-3 text-right text-violet-700">{formatCurrency(row.subscriptionAmount)}</td><td className="px-4 py-3 text-right">{row.nonSubscriptionCount}</td><td className="px-4 py-3 text-right text-blue-700">{formatCurrency(row.nonSubscriptionAmount)}</td><td className="px-4 py-3 text-right font-semibold">{formatCurrency(row.totalAmount)}</td></tr>)}</tbody>
<tfoot className="border-t-2 bg-muted/50 font-semibold"><tr><td className="px-4 py-3">Total</td><td colSpan={2} className="px-4 py-3 text-right text-violet-700">{formatCurrency(totals.subscriptionAmount)}</td><td colSpan={2} className="px-4 py-3 text-right text-blue-700">{formatCurrency(totals.nonSubscriptionAmount)}</td><td className="px-4 py-3 text-right text-emerald-700">{formatCurrency(totals.totalAmount)}</td></tr></tfoot>
</table>
</div>
)}
</CardContent>
</Card>
</div>
</DashboardLayout>
);
}

View File

@@ -677,7 +677,7 @@ export default function Settings() {
const [sftpAutoExport, setSftpAutoExport] = useState(false);
const [sftpRecipientFilter, setSftpRecipientFilter] = useState("");
const [llmLogsRetentionMonths, setLlmLogsRetentionMonths] = useState(3);
const [aiProvider, setAiProvider] = useState<"mistral" | "manus" | "gemini">("mistral");
const [aiProvider, setAiProvider] = useState<"mistral" | "manus" | "gemini" | "referentiel">("mistral");
const [mistralApiKey, setMistralApiKey] = useState("");
const [manusForgeApiKey, setManusForgeApiKey] = useState("");
const [manusForgeApiUrl, setManusForgeApiUrl] = useState("");
@@ -685,6 +685,8 @@ export default function Settings() {
const [showMistralKey, setShowMistralKey] = useState(false);
const [showManusKey, setShowManusKey] = useState(false);
const [showGeminiKey, setShowGeminiKey] = useState(false);
const [referentialEngine, setReferentialEngine] = useState<{ engineName: string | null; model: string | null } | null>(null);
const [referentialEngineError, setReferentialEngineError] = useState<string | null>(null);
useEffect(() => {
if (settings) {
@@ -722,6 +724,30 @@ export default function Settings() {
},
});
const testReferentielIaMutation = trpc.settings.testReferentielIa.useMutation({
onSuccess: (data) => {
setReferentialEngine({ engineName: data.engineName, model: data.model });
setReferentialEngineError(null);
const engine = data.engineName ? ` Moteur sélectionné : ${data.engineName}.` : "";
const model = data.model ? ` Modèle : ${data.model}.` : "";
toast.success(`Connexion au Référentiel IA réussie.${engine}${model}`);
},
onError: (error) => {
setReferentialEngine(null);
setReferentialEngineError(error.message || "Le moteur sélectionné n'a pas pu être obtenu.");
toast.error(error.message || "Connexion au Référentiel IA impossible");
},
});
const selectReferentielIa = () => {
if (testReferentielIaMutation.isPending) return;
setAiProvider("referentiel");
setReferentialEngine(null);
setReferentialEngineError(null);
// Cette demande interroge uniquement la recommandation centralisée, sans analyser de facture.
testReferentielIaMutation.mutate();
};
const testSftpMutation = trpc.sftp.testConnection.useMutation({
onSuccess: (data) => {
if (data.success) {
@@ -857,7 +883,7 @@ export default function Settings() {
<CardContent className="space-y-6 pt-6">
{/* Provider selector */}
<div className="grid grid-cols-3 gap-4">
<div className="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-4 gap-4">
<button
type="button"
onClick={() => setAiProvider("mistral")}
@@ -939,6 +965,32 @@ export default function Settings() {
<Badge className="bg-blue-500 text-white text-xs">Actif</Badge>
)}
</button>
<button
type="button"
onClick={selectReferentielIa}
className={`relative flex flex-col items-center gap-3 p-5 rounded-xl border-2 transition-all cursor-pointer ${
aiProvider === "referentiel"
? "border-violet-500 bg-violet-50 dark:bg-violet-950/20 shadow-md"
: "border-muted hover:border-violet-300 bg-background"
}`}
>
{aiProvider === "referentiel" && (
<div className="absolute top-2 right-2">
<CheckCircle className="w-5 h-5 text-violet-500" />
</div>
)}
<div className="p-3 bg-violet-100 dark:bg-violet-900/30 rounded-xl">
<Brain className="w-8 h-8 text-violet-600" />
</div>
<div className="text-center">
<p className="font-bold text-base">Référentiel IA</p>
<p className="text-xs text-muted-foreground mt-1">Choix centralisé<br/>Moteur et modèle décidés par le référentiel</p>
</div>
{aiProvider === "referentiel" && (
<Badge className="bg-violet-500 text-white text-xs">Actif</Badge>
)}
</button>
</div>
{/* Mistral config */}
@@ -1055,6 +1107,51 @@ export default function Settings() {
</div>
)}
{aiProvider === "referentiel" && (
<div className="space-y-4 p-4 bg-violet-50 dark:bg-violet-950/10 rounded-xl border border-violet-200 dark:border-violet-800">
<div className="flex items-start justify-between gap-4">
<div>
<p className="text-sm font-semibold text-violet-700 dark:text-violet-400 uppercase tracking-wide">Configuration centralisée</p>
<p className="text-sm text-violet-900 dark:text-violet-100 mt-2">
L’analyse est envoyée à la passerelle du Référentiel IA. Le moteur, le modèle, les droits et les limites d’usage sont ceux sélectionnés pour <strong>Démat. Facturation DSI</strong> dans le Référentiel.
</p>
<p className="text-xs text-violet-700 dark:text-violet-300 mt-2">
Aucune clé du Référentiel n’est affichée ni enregistrée dans ces paramètres.
</p>
</div>
<Button
type="button"
variant="outline"
className="border-violet-300 text-violet-700 hover:bg-violet-100 shrink-0"
onClick={() => testReferentielIaMutation.mutate()}
disabled={testReferentielIaMutation.isPending}
>
{testReferentielIaMutation.isPending ? <Loader2 className="w-4 h-4 animate-spin mr-2" /> : <CheckCircle className="w-4 h-4 mr-2" />}
Vérifier la connexion
</Button>
</div>
<div className="rounded-lg border border-violet-200 bg-white/70 p-3 text-sm dark:border-violet-800 dark:bg-violet-950/20" aria-live="polite">
{testReferentielIaMutation.isPending ? (
<div className="flex items-center gap-2 text-violet-800 dark:text-violet-200">
<Loader2 className="h-4 w-4 animate-spin" />
Recherche du moteur décidé par le Référentiel IA…
</div>
) : referentialEngine ? (
<div className="space-y-1 text-violet-900 dark:text-violet-100">
<p className="font-semibold">Moteur qui sera utilisé : {referentialEngine.engineName || "défini par le Référentiel IA"}</p>
<p className="text-xs text-violet-700 dark:text-violet-300">
{referentialEngine.model ? `Modèle : ${referentialEngine.model}` : "Le modèle est également déterminé par le Référentiel IA."}
</p>
</div>
) : referentialEngineError ? (
<p className="text-violet-800 dark:text-violet-200">Impossible d’obtenir le moteur. Utilisez « Vérifier la connexion » pour réessayer.</p>
) : (
<p className="text-violet-800 dark:text-violet-200">Le moteur recommandé s’affichera automatiquement après la sélection.</p>
)}
</div>
</div>
)}
</CardContent>
</Card>
@@ -1073,20 +1170,27 @@ export default function Settings() {
</div>
</CardHeader>
<CardContent className="space-y-6 pt-6">
<div className="space-y-3">
<Label htmlFor="llmModel" className="text-base font-semibold">Modèle IA (override)</Label>
<Input
id="llmModel"
value={llmModel}
onChange={(e) => setLlmModel(e.target.value)}
placeholder="mistral-large-latest"
className="h-11"
/>
<p className="text-sm text-muted-foreground flex items-center gap-2">
<AlertCircle className="w-4 h-4" />
Nom du modèle Mistral à utiliser pour l'extraction
</p>
</div>
{aiProvider === "referentiel" ? (
<div className="space-y-2 p-4 rounded-lg bg-violet-50 dark:bg-violet-950/10 border border-violet-200 dark:border-violet-800">
<p className="font-medium text-violet-900 dark:text-violet-100">Modèle décidé par le Référentiel IA</p>
<p className="text-sm text-violet-700 dark:text-violet-300">La surcharge locale est volontairement désactivée pour garantir un moteur unique, piloté et tracé au niveau Santinova.</p>
</div>
) : (
<div className="space-y-3">
<Label htmlFor="llmModel" className="text-base font-semibold">Modèle IA (override)</Label>
<Input
id="llmModel"
value={llmModel}
onChange={(e) => setLlmModel(e.target.value)}
placeholder="mistral-large-latest"
className="h-11"
/>
<p className="text-sm text-muted-foreground flex items-center gap-2">
<AlertCircle className="w-4 h-4" />
Nom du modèle à utiliser pour l'extraction directe
</p>
</div>
)}
<div className="space-y-3">
<Label htmlFor="llmLogsRetentionMonths" className="text-base font-semibold">

View File

@@ -17,6 +17,7 @@ import {
} from "lucide-react";
import { toast } from "sonner";
import { useLocation } from "wouter";
import { parseImportResultSummary } from "@shared/importResultSummary";
interface UploadedFile {
name: string;
@@ -24,6 +25,9 @@ interface UploadedFile {
status: "pending" | "processing" | "completed" | "error";
progress?: string;
invoicesDetected?: number;
importedCount?: number;
duplicateCount?: number;
errorCount?: number;
}
export default function Upload() {
@@ -66,18 +70,22 @@ export default function Upload() {
prev.map((f) => {
if (f.sourceFileId !== sf.id) return f;
if (f.status === sf.processingStatus) return f;
const summary = parseImportResultSummary(sf.processingProgress);
return {
...f,
status: (sf.processingStatus ?? "pending") as UploadedFile["status"],
progress: sf.processingProgress ?? undefined,
invoicesDetected: sf.totalInvoicesDetected ?? undefined,
importedCount: summary?.importedCount,
duplicateCount: summary?.duplicateCount,
errorCount: summary?.errorCount,
};
})
);
});
}, [sourceFiles]);
const uploadFile = async (file: File): Promise<number | null> => {
const uploadFile = async (file: File): Promise<{ sourceFileId: number; reprocessed: boolean } | null> => {
return new Promise((resolve) => {
const reader = new FileReader();
reader.onload = async () => {
@@ -87,7 +95,7 @@ export default function Upload() {
fileName: file.name,
fileData: base64!,
});
resolve(data.sourceFileId);
resolve(data);
} catch (err: any) {
toast.error(`Erreur pour ${file.name} : ${err.message || "Erreur inconnue"}`);
resolve(null);
@@ -110,18 +118,27 @@ export default function Upload() {
setIsUploading(true);
toast.info(`Envoi de ${pdfFiles.length} fichier(s)...`);
let acceptedFiles = 0;
for (const file of pdfFiles) {
const sourceFileId = await uploadFile(file);
if (sourceFileId !== null) {
const upload = await uploadFile(file);
if (upload !== null) {
acceptedFiles++;
setUploadedFiles((prev) => [
...prev,
{ name: file.name, sourceFileId, status: "processing" },
{ name: file.name, sourceFileId: upload.sourceFileId, status: "processing" },
]);
if (upload.reprocessed) {
toast.info(`${file.name} : source existante retrouvée, nouvelle analyse lancée.`);
}
}
}
setIsUploading(false);
toast.success(`${pdfFiles.length} fichier(s) envoyé(s) — traitement en cours`);
if (acceptedFiles > 0) {
toast.success(`${acceptedFiles} fichier(s) envoyé(s) — traitement en cours`);
} else {
toast.warning("Aucun fichier n’a été lancé : consultez le message d’erreur affiché.");
}
};
const handleDragOver = useCallback((e: React.DragEvent) => {
@@ -156,6 +173,10 @@ export default function Upload() {
const processingCount = uploadedFiles.filter(
(f) => f.status === "processing" || f.status === "pending"
).length;
const importedInvoiceCount = uploadedFiles.reduce(
(total, file) => total + (file.importedCount ?? 0),
0,
);
return (
<DashboardLayout>
@@ -273,8 +294,11 @@ export default function Upload() {
<div>
<CardTitle>Fichiers importés</CardTitle>
<CardDescription>
{completedCount > 0 && (
<span className="text-green-600 font-medium">{completedCount} terminé(s) </span>
{completedCount > 0 && importedInvoiceCount > 0 && (
<span className="text-green-600 font-medium">{importedInvoiceCount} facture(s) créée(s) </span>
)}
{completedCount > 0 && importedInvoiceCount === 0 && (
<span className="text-amber-700 font-medium">{completedCount} terminé(s), aucune facture créée </span>
)}
{processingCount > 0 && (
<span className="text-blue-600 font-medium">{processingCount} en cours </span>
@@ -285,7 +309,7 @@ export default function Upload() {
</CardDescription>
</div>
<div className="flex gap-2">
{completedCount > 0 && (
{importedInvoiceCount > 0 && (
<Button
size="sm"
onClick={() => setLocation("/invoices")}
@@ -323,10 +347,16 @@ export default function Upload() {
{f.status === "processing" && (
<p className="text-xs text-muted-foreground">{f.progress || "Traitement en cours..."}</p>
)}
{f.status === "completed" && f.invoicesDetected !== undefined && (
<p className="text-xs text-green-600">
{f.invoicesDetected} facture(s) détectée(s)
</p>
{f.status === "completed" && f.importedCount !== undefined && (
f.importedCount > 0 ? (
<p className="text-xs text-green-600">
{f.importedCount} facture(s) créée(s)
</p>
) : (
<p className="text-xs text-amber-700">
Aucune facture créée{f.duplicateCount ? ` : ${f.duplicateCount} doublon(s) ou facture(s) supprimée(s)` : ""}
</p>
)
)}
{f.status === "error" && (
<p className="text-xs text-red-500">{f.progress || "Erreur de traitement"}</p>

View File

@@ -7,6 +7,7 @@ import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { Badge } from "@/components/ui/badge";
import { isValidWebPortalUrl, normalizeWebPortalUrl } from "@shared/webPortalUrl";
import {
Dialog,
DialogContent,
@@ -24,7 +25,7 @@ import {
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { Globe, Plus, Pencil, Trash2, Key, CheckCircle, XCircle, Clock, RefreshCw } from "lucide-react";
import { Globe, Plus, Pencil, Trash2, Key, CheckCircle, XCircle, Clock, RefreshCw, Play } from "lucide-react";
import { toast } from "sonner";
const CONNECTOR_TYPES = [
@@ -84,6 +85,7 @@ export default function WebImportSources() {
const [showForm, setShowForm] = useState(false);
const [editSource, setEditSource] = useState<Source | null>(null);
const [deleteId, setDeleteId] = useState<number | null>(null);
const [manualImportId, setManualImportId] = useState<number | null>(null);
const [showToken, setShowToken] = useState<number | null>(null);
const [form, setForm] = useState<FormData>(emptyForm);
@@ -123,6 +125,21 @@ export default function WebImportSources() {
onError: (e) => toast.error("Erreur", { description: e.message }),
});
const runManualMutation = trpc.webImportSources.runManual.useMutation({
onSuccess: (result) => {
toast.success("Import manuel terminé", {
description: `${result.imported} facture(s) importée(s), ${result.duplicates} doublon(s) ignoré(s).`,
});
setManualImportId(null);
refetch();
},
onError: (e) => {
toast.error("Import manuel en erreur", { description: e.message });
setManualImportId(null);
refetch();
},
});
function openCreate() {
setForm(emptyForm);
setEditSource(null);
@@ -158,12 +175,20 @@ export default function WebImportSources() {
toast.error("Champs requis", { description: "Nom, identifiant et mot de passe sont obligatoires." });
return;
}
const portalUrl = normalizeWebPortalUrl(form.portalUrl);
if (!isValidWebPortalUrl(portalUrl)) {
toast.error("URL de portail invalide", {
description: "Saisissez une adresse HTTP(S), par exemple https://espaceclient.exemple.fr.",
});
return;
}
if (editSource) {
const updateData: any = { id: editSource.id, ...form };
const updateData: any = { id: editSource.id, ...form, portalUrl };
if (!form.loginPassword) delete updateData.loginPassword; // Ne pas écraser si vide
updateMutation.mutate(updateData);
} else {
createMutation.mutate(form);
createMutation.mutate({ ...form, portalUrl });
}
}
@@ -244,6 +269,21 @@ export default function WebImportSources() {
</div>
</div>
<div className="flex items-center gap-2 shrink-0">
<Button
variant="outline"
size="sm"
className="gap-1 text-xs"
disabled={source.connectorType !== "sfr" || runManualMutation.isPending}
title={source.connectorType === "sfr" ? "Lancer une lecture ponctuelle" : "Ce connecteur ne dispose pas encore d’un moteur d’import manuel"}
onClick={() => setManualImportId(source.id)}
>
{runManualMutation.isPending && manualImportId === source.id ? (
<RefreshCw className="w-3 h-3 animate-spin" />
) : (
<Play className="w-3 h-3" />
)}
Importer maintenant
</Button>
<Button
variant="outline"
size="sm"
@@ -316,6 +356,7 @@ export default function WebImportSources() {
onChange={e => setForm(f => ({ ...f, portalUrl: e.target.value }))}
placeholder="https://..."
/>
<p className="mt-1 text-xs text-muted-foreground">Le préfixe https:// est ajouté automatiquement si nécessaire.</p>
</div>
<div>
<Label>Identifiant (email ou login)</Label>
@@ -378,6 +419,26 @@ export default function WebImportSources() {
</DialogContent>
</Dialog>
<AlertDialog open={manualImportId !== null} onOpenChange={(open) => { if (!open && !runManualMutation.isPending) setManualImportId(null); }}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Lancer l’import manuel ?</AlertDialogTitle>
<AlertDialogDescription>
L’application se connectera ponctuellement à l’espace client SFR, téléchargera les nouvelles factures puis les analysera via le flux normal. Cette action ne crée aucune planification automatique.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={runManualMutation.isPending}>Annuler</AlertDialogCancel>
<AlertDialogAction
disabled={runManualMutation.isPending}
onClick={() => manualImportId !== null && runManualMutation.mutate({ id: manualImportId })}
>
{runManualMutation.isPending ? "Import en cours…" : "Lancer l’import"}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{/* Confirmation suppression */}
<AlertDialog open={deleteId !== null} onOpenChange={(open) => { if (!open) setDeleteId(null); }}>
<AlertDialogContent>

View File

@@ -21,6 +21,25 @@ services:
retries: 5
start_period: 30s
# ============================================================
# Migration versionnée (lancée explicitement avec le profil migration)
# ============================================================
demat-facturation-migrate:
build:
context: .
dockerfile: Dockerfile
target: builder
command: ["./node_modules/.bin/drizzle-kit", "migrate"]
depends_on:
demat-facturation-db:
condition: service_healthy
environment:
DATABASE_URL: mysql://root:demat_facturation_db_pass_2026@demat-facturation-db:3306/gestion_facturation
networks:
- demat-facturation-internal
profiles: ["migration"]
restart: "no"
# ============================================================
# Application (Backend + Frontend)
# ============================================================
@@ -39,7 +58,15 @@ services:
DATABASE_URL: mysql://root:demat_facturation_db_pass_2026@demat-facturation-db:3306/gestion_facturation
JWT_SECRET: demat-facturation-jwt-secret-recette-2026
MISTRAL_API_KEY: ${MISTRAL_API_KEY:-}
# Passerelle centralisée : le jeton reste dans l'environnement serveur.
REFERENTIEL_IA_URL: ${REFERENTIEL_IA_URL:-https://referentiel-ia-santinova.recette.santinova-soft.org}
REFERENTIEL_IA_TOKEN: ${REFERENTIEL_IA_TOKEN:-}
REFERENTIEL_IA_TOKEN_FILE: /run/secrets/referentiel_ia_token
UPLOAD_DIR: /app/uploads
# Chemin unique utilisé par le serveur et vérifié au démarrage.
STORAGE_BASE_PATH: /app/storage
# Le déploiement Santinova requiert le volume historique des PDF.
REQUIRE_STORAGE_VOLUME: "true"
# Manus OAuth (optionnel en recette)
VITE_APP_ID: ${VITE_APP_ID:-demat-facturation-recette}
OAUTH_SERVER_URL: ${OAUTH_SERVER_URL:-}
@@ -48,17 +75,32 @@ services:
OWNER_NAME: ${OWNER_NAME:-Administrateur}
volumes:
- demat_facturation_uploads:/app/uploads
# Les factures source et leurs métadonnées sont servies sous /storage.
# Il est externe et n'est donc jamais recréé ni supprimé par Compose.
- type: volume
source: demat_facturation_storage
target: /app/storage
volume:
nocopy: true
secrets:
- source: referentiel_ia_token
target: referentiel_ia_token
mode: 0400
# L’application doit utiliser eth0 sur le réseau de données ; l’ordre évite
# toute inversion d’interface lors d’une recréation Docker multi-réseaux.
networks:
- demat-facturation-internal
- web
demat-facturation-internal:
priority: 1000
web:
priority: 100
labels:
- "traefik.enable=true"
- "traefik.http.routers.demat-facturation.rule=Host(`demat-facturation.recette.santinova-soft.org`)"
- "traefik.http.routers.demat-facturation.rule=Host(`demat-facturation-santinova.recette.santinova-soft.org`)"
- "traefik.http.routers.demat-facturation.entrypoints=websecure"
- "traefik.http.routers.demat-facturation.tls.certresolver=letsencrypt"
- "traefik.http.services.demat-facturation.loadbalancer.server.port=3000"
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:3000/api/health"]
test: ["CMD-SHELL", "grep -qs ' /app/storage ' /proc/mounts && test -w /app/storage && curl -fsS http://localhost:3000/api/health"]
interval: 30s
timeout: 10s
retries: 3
@@ -69,6 +111,13 @@ volumes:
driver: local
demat_facturation_uploads:
driver: local
demat_facturation_storage:
external: true
name: ${STORAGE_VOLUME_NAME:-demat-facturation-dsi_demat_facturation_storage}
secrets:
referentiel_ia_token:
file: /opt/manus-secrets/demat-facturation-referentiel-ia-token
networks:
demat-facturation-internal:

View File

@@ -0,0 +1,2 @@
ALTER TABLE `sourceFiles` ADD `contentHash` varchar(64);--> statement-breakpoint
ALTER TABLE `sourceFiles` ADD CONSTRAINT `source_file_content_hash_unique` UNIQUE(`contentHash`);

View File

@@ -0,0 +1 @@
ALTER TABLE `importLogs` ADD `importTrigger` enum('manual','automatic','unknown') DEFAULT 'unknown' NOT NULL;

View File

@@ -0,0 +1,17 @@
CREATE TABLE `exportAutomationRules` (
`id` int AUTO_INCREMENT NOT NULL,
`userId` int NOT NULL,
`name` varchar(255) NOT NULL,
`isActive` int NOT NULL DEFAULT 1,
`priority` int NOT NULL DEFAULT 0,
`conditionField` enum('recipientName','ventilationComptable') NOT NULL,
`conditionValue` varchar(255) NOT NULL,
`destinationType` enum('local','teams','sharepoint') NOT NULL,
`destinationPath` text NOT NULL,
`openInBrowser` int NOT NULL DEFAULT 0,
`createdAt` timestamp NOT NULL DEFAULT (now()),
`updatedAt` timestamp NOT NULL DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT `exportAutomationRules_id` PRIMARY KEY(`id`)
);
--> statement-breakpoint
CREATE INDEX `export_rule_user_priority_idx` ON `exportAutomationRules` (`userId`,`priority`);

View File

@@ -0,0 +1,3 @@
ALTER TABLE `llmLogs` ADD `resolvedProvider` varchar(30);--> statement-breakpoint
ALTER TABLE `llmLogs` ADD `resolvedModel` varchar(80);--> statement-breakpoint
ALTER TABLE `llmLogs` ADD `transport` varchar(30);

View File

@@ -0,0 +1,2 @@
ALTER TABLE `importSettings` MODIFY COLUMN `aiProvider` enum('mistral','manus','gemini','referentiel') NOT NULL DEFAULT 'mistral';--> statement-breakpoint
ALTER TABLE `userSettings` MODIFY COLUMN `aiProvider` enum('mistral','manus','gemini','referentiel') NOT NULL DEFAULT 'mistral';

View File

@@ -0,0 +1,11 @@
-- Rattrapage additif : certains environnements historiques possèdent le code
-- de blacklist sans avoir exécuté la migration 0035 correspondante.
CREATE TABLE IF NOT EXISTS `deletedInvoices` (
`id` int AUTO_INCREMENT NOT NULL,
`userId` int NOT NULL,
`invoiceNumber` varchar(100) NOT NULL,
`totalAmount` varchar(50),
`supplierName` varchar(255),
`deletedAt` timestamp NOT NULL DEFAULT (now()),
CONSTRAINT `deletedInvoices_id` PRIMARY KEY(`id`)
);

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -260,6 +260,48 @@
"when": 1785419093588,
"tag": "0036_broken_rattler",
"breakpoints": true
},
{
"idx": 37,
"version": "5",
"when": 1787394418464,
"tag": "0037_goofy_quentin_quire",
"breakpoints": true
},
{
"idx": 38,
"version": "5",
"when": 1788349114141,
"tag": "0038_late_thunderbolt",
"breakpoints": true
},
{
"idx": 39,
"version": "5",
"when": 1788350633569,
"tag": "0039_good_carmella_unuscione",
"breakpoints": true
},
{
"idx": 40,
"version": "5",
"when": 1788942350737,
"tag": "0040_sudden_flatman",
"breakpoints": true
},
{
"idx": 41,
"version": "5",
"when": 1790785209695,
"tag": "0041_acoustic_microchip",
"breakpoints": true
},
{
"idx": 42,
"version": "5",
"when": 1790960584104,
"tag": "0042_deleted_invoices_blacklist",
"breakpoints": true
}
]
}
}

View File

@@ -1,4 +1,4 @@
import { int, mysqlEnum, mysqlTable, text, timestamp, varchar, uniqueIndex, decimal } from "drizzle-orm/mysql-core";
import { decimal, index, int, mysqlEnum, mysqlTable, text, timestamp, uniqueIndex, varchar } from "drizzle-orm/mysql-core";
/**
* Core user table backing auth flow.
@@ -36,12 +36,16 @@ export const sourceFiles = mysqlTable("sourceFiles", {
fileName: varchar("fileName", { length: 255 }).notNull(),
fileKey: text("fileKey").notNull(), // Local storage key with YYYY-MM prefix
fileUrl: text("fileUrl").notNull(), // Public URL
/** Empreinte du PDF source, globale à l'application pour bloquer tout réimport identique. */
contentHash: varchar("contentHash", { length: 64 }),
totalInvoicesDetected: int("totalInvoicesDetected").default(0).notNull(),
processingStatus: mysqlEnum("processingStatus", ["processing", "completed", "error"]).default("processing").notNull(),
processingProgress: varchar("processingProgress", { length: 255 }), // Progress message (e.g., "Extraction 3/9 factures...")
createdAt: timestamp("createdAt").defaultNow().notNull(),
updatedAt: timestamp("updatedAt").defaultNow().onUpdateNow().notNull(),
});
}, (table) => ({
contentHashIdx: uniqueIndex("source_file_content_hash_unique").on(table.contentHash),
}));
export type SourceFile = typeof sourceFiles.$inferSelect;
export type InsertSourceFile = typeof sourceFiles.$inferInsert;
@@ -149,7 +153,7 @@ export const userSettings = mysqlTable("userSettings", {
// Seuil de confiance pour les apprentissages IA
learningConfidenceThreshold: int("learningConfidenceThreshold").default(2).notNull(), // Nombre minimum d'applications pour marquer un apprentissage comme Confirmé
// AI Engine configuration
aiProvider: mysqlEnum("aiProvider", ["mistral", "manus", "gemini"]).default("mistral").notNull(), // AI provider: 'mistral', 'manus' or 'gemini'
aiProvider: mysqlEnum("aiProvider", ["mistral", "manus", "gemini", "referentiel"]).default("mistral").notNull(), // Moteur direct ou sélection centralisée par le Référentiel IA
mistralApiKey: text("mistralApiKey"), // Mistral API key (overrides env MISTRAL_API_KEY)
manusForgeApiKey: text("manusForgeApiKey"), // Manus Forge API key (overrides env BUILT_IN_FORGE_API_KEY)
manusForgeApiUrl: text("manusForgeApiUrl"), // Manus Forge API URL (overrides env BUILT_IN_FORGE_API_URL)
@@ -197,7 +201,7 @@ export const importSettings = mysqlTable("importSettings", {
azureSecretExpiresAt: timestamp("azureSecretExpiresAt"), // Azure AD Client Secret expiration date
// AI Engine settings
aiProvider: mysqlEnum("aiProvider", ["mistral", "manus", "gemini"]).default("mistral").notNull(), // AI provider for invoice extraction
aiProvider: mysqlEnum("aiProvider", ["mistral", "manus", "gemini", "referentiel"]).default("mistral").notNull(), // Moteur direct ou sélection centralisée par le Référentiel IA
mistralApiKey: text("mistralApiKey"), // Mistral API key (overrides env MISTRAL_API_KEY)
manusForgeApiKey: text("manusForgeApiKey"), // Manus Forge API key (overrides env BUILT_IN_FORGE_API_KEY)
manusForgeApiUrl: text("manusForgeApiUrl"), // Manus Forge API URL (overrides env BUILT_IN_FORGE_API_URL)
@@ -227,6 +231,8 @@ export const importLogs = mysqlTable("importLogs", {
warningMessage: text("warningMessage"), // Warning message (e.g. quota exhausted)
/** Source du mode d'import : 'file' = upload manuel, 'folder' = dossier automatique, 'email' = import par email */
importSource: mysqlEnum("importSource", ["file", "folder", "email"]).default("file").notNull(),
/** Déclencheur : manuel, planifié automatiquement, ou non tracé pour les historiques antérieurs. */
importTrigger: mysqlEnum("importTrigger", ["manual", "automatic", "unknown"]).default("unknown").notNull(),
importedAt: timestamp("importedAt").defaultNow().notNull(),
});
@@ -242,7 +248,14 @@ export const llmLogs = mysqlTable("llmLogs", {
sourceFileId: int("sourceFileId"), // Optional: link to source file if applicable
invoiceId: int("invoiceId"), // Optional: link to invoice if applicable
operation: varchar("operation", { length: 50 }).notNull(), // "detection" or "extraction"
model: varchar("model", { length: 50 }).notNull(), // LLM model used
/** Modèle demandé ou historique, conservé pour compatibilité. */
model: varchar("model", { length: 50 }).notNull(),
/** Fournisseur réellement résolu par l’adaptateur au moment de l’appel. */
resolvedProvider: varchar("resolvedProvider", { length: 30 }),
/** Modèle réellement inclus dans le payload envoyé au fournisseur. */
resolvedModel: varchar("resolvedModel", { length: 80 }),
/** Catégorie de transport, sans URL ni secret. */
transport: varchar("transport", { length: 30 }),
promptSent: text("promptSent").notNull(), // Full prompt sent to LLM
rawResponse: text("rawResponse").notNull(), // Raw response from LLM (before cleaning)
cleanedResponse: text("cleanedResponse"), // Response after markdown cleaning
@@ -318,6 +331,31 @@ export const automationRules = mysqlTable("automationRules", {
export type AutomationRule = typeof automationRules.$inferSelect;
export type InsertAutomationRule = typeof automationRules.$inferInsert;
/**
* Règles de destination appliquées lors de la validation BAP.
* Elles sont séparées des règles d’import, afin de ne jamais modifier les
* champs d’une facture au moment de son export.
*/
export const exportAutomationRules = mysqlTable("exportAutomationRules", {
id: int("id").autoincrement().primaryKey(),
userId: int("userId").notNull(),
name: varchar("name", { length: 255 }).notNull(),
isActive: int("isActive").default(1).notNull(),
priority: int("priority").default(0).notNull(),
conditionField: mysqlEnum("conditionField", ["recipientName", "ventilationComptable"]).notNull(),
conditionValue: varchar("conditionValue", { length: 255 }).notNull(),
destinationType: mysqlEnum("destinationType", ["local", "teams", "sharepoint"]).notNull(),
destinationPath: text("destinationPath").notNull(),
openInBrowser: int("openInBrowser").default(0).notNull(),
createdAt: timestamp("createdAt").defaultNow().notNull(),
updatedAt: timestamp("updatedAt").defaultNow().onUpdateNow().notNull(),
}, (table) => ({
userPriorityIdx: index("export_rule_user_priority_idx").on(table.userId, table.priority),
}));
export type ExportAutomationRule = typeof exportAutomationRules.$inferSelect;
export type InsertExportAutomationRule = typeof exportAutomationRules.$inferInsert;
/**
* LLM Fields Configuration table
* Stores configuration for each field used in invoice extraction

View File

@@ -53,7 +53,6 @@
"@types/archiver": "^7.0.0",
"@types/bcrypt": "^6.0.0",
"@types/chokidar": "^2.1.7",
"@types/imap": "^0.8.43",
"@types/jsonwebtoken": "^9.0.10",
"@types/mailparser": "^3.4.6",
"@types/ssh2-sftp-client": "^9.0.6",
@@ -71,7 +70,7 @@
"embla-carousel-react": "^8.6.0",
"express": "^4.21.2",
"framer-motion": "^12.23.22",
"imap": "^0.8.19",
"imapflow": "^1.7.2",
"input-otp": "^1.4.2",
"jose": "6.1.0",
"jsonwebtoken": "^9.0.3",
@@ -86,6 +85,7 @@
"pdf-parse": "^2.4.5",
"pdf2json": "^4.0.2",
"pdfjs-dist": "^5.4.624",
"playwright-core": "^1.63.0",
"react": "^19.2.1",
"react-day-picker": "^9.11.1",
"react-dom": "^19.2.1",

219
pnpm-lock.yaml generated
View File

@@ -133,9 +133,6 @@ importers:
'@types/chokidar':
specifier: ^2.1.7
version: 2.1.7
'@types/imap':
specifier: ^0.8.43
version: 0.8.43
'@types/jsonwebtoken':
specifier: ^9.0.10
version: 9.0.10
@@ -187,9 +184,9 @@ importers:
framer-motion:
specifier: ^12.23.22
version: 12.23.22(react-dom@19.2.1(react@19.2.1))(react@19.2.1)
imap:
specifier: ^0.8.19
version: 0.8.19
imapflow:
specifier: ^1.7.2
version: 1.7.2
input-otp:
specifier: ^1.4.2
version: 1.4.2(react-dom@19.2.1(react@19.2.1))(react@19.2.1)
@@ -232,6 +229,9 @@ importers:
pdfjs-dist:
specifier: ^5.4.624
version: 5.4.624
playwright-core:
specifier: ^1.63.0
version: 1.63.0
react:
specifier: ^19.2.1
version: 19.2.1
@@ -1438,6 +1438,9 @@ packages:
'@pdf-lib/upng@1.0.1':
resolution: {integrity: sha512-dQK2FUMQtowVP00mtIksrlZhdFXQZPC+taih1q4CvPZ5vqdxR/LKBaFg0oAfzd1GlHZXXSPdQfzQnt+ViGvEIQ==}
'@pinojs/redact@0.4.0':
resolution: {integrity: sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==}
'@pkgjs/parseargs@0.11.0':
resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==}
engines: {node: '>=14'}
@@ -2580,9 +2583,6 @@ packages:
'@types/http-errors@2.0.5':
resolution: {integrity: sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==}
'@types/imap@0.8.43':
resolution: {integrity: sha512-POPoqrDax9mxM2N4ITZYCWaFtg1ORVfzJe4S7xwSh9aHawdEb7FwWTJYiAhzIvWp7DM+6BajnzYOwZ1BUrqtow==}
'@types/jsonwebtoken@9.0.10':
resolution: {integrity: sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==}
@@ -2681,6 +2681,9 @@ packages:
'@vitest/utils@2.1.9':
resolution: {integrity: sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ==}
'@zone-eu/mailsplit@5.4.15':
resolution: {integrity: sha512-c7ZpxauvF4AEkDJlKDYO7iMUtMuqJMBnDWNff1cyx+d7zaBVR3iFEmXhNHOVoMmVyVF3pTZLsLIJsEFKDldOAA==}
'@zone-eu/mailsplit@5.4.8':
resolution: {integrity: sha512-eEyACj4JZ7sjzRvy26QhLgKEMWwQbsw1+QZnlLX+/gihcNH07lVPOcnwf5U6UAL7gkc//J3jVd76o/WS+taUiA==}
@@ -2744,6 +2747,10 @@ packages:
asynckit@0.4.0:
resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==}
atomic-sleep@1.0.0:
resolution: {integrity: sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==}
engines: {node: '>=8.0.0'}
autoprefixer@10.4.21:
resolution: {integrity: sha512-O+A6LWV5LDHSJD3LjHYoNi4VLsj/Whi7k6zG12xTYaU4cQ8oxQGckXNX8cRHK5yOZ/ppVHe0ZBXGzSV9jXdVbQ==}
engines: {node: ^10 || ^12 || >=14}
@@ -3542,12 +3549,15 @@ packages:
resolution: {integrity: sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==}
engines: {node: '>=0.10.0'}
iconv-lite@0.7.3:
resolution: {integrity: sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==}
engines: {node: '>=0.10.0'}
ieee754@1.2.1:
resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==}
imap@0.8.19:
resolution: {integrity: sha512-z5DxEA1uRnZG73UcPA4ES5NSCGnPuuouUx43OPX7KZx1yzq3N8/vx2mtXEShT5inxB3pRgnfG1hijfu7XN2YMw==}
engines: {node: '>=0.8.0'}
imapflow@1.7.2:
resolution: {integrity: sha512-1pWZgWQ/M2Q7kPSW7Sp7QDn+ZPEqs/9IymYh34RY+3J7d3vfPayhSmRAl0tB7weblGU0SR/t7eYES3TW6vSiOQ==}
inherits@2.0.4:
resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==}
@@ -3565,6 +3575,10 @@ packages:
iobuffer@5.4.0:
resolution: {integrity: sha512-DRebOWuqDvxunfkNJAlc3IzWIPD5xVxwUNbHr7xKB8E6aLJxIPfNX3CoMJghcFjpv6RWQsrcJbghtEwSPoJqMA==}
ip-address@10.5.0:
resolution: {integrity: sha512-R5SnVLJmgYYvf2F2ZgwSBnelz5G4q5AxIC277GDfUaNbrZKNANcBC7RHqYYePlszf4kBolVkJauG0ZjHHFh55g==}
engines: {node: '>= 12'}
ipaddr.js@1.9.1:
resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==}
engines: {node: '>= 0.10'}
@@ -3598,9 +3612,6 @@ packages:
resolution: {integrity: sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw==}
engines: {node: '>=16'}
isarray@0.0.1:
resolution: {integrity: sha512-D2S+3GLxWH+uhrNEcoh/fnmYeP8E8/zHl644d/jdA0g2uyXvy3sb0qxotE+ne0LtccHknQzWwZEzhak7oJ0COQ==}
isarray@1.0.0:
resolution: {integrity: sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==}
@@ -3661,6 +3672,9 @@ packages:
libmime@5.3.7:
resolution: {integrity: sha512-FlDb3Wtha8P01kTL3P9M+ZDNDWPKPmKHWaU/cG/lg5pfuAwdflVpZE+wm9m7pKmC5ww6s+zTxBKS1p6yl3KpSw==}
libmime@5.4.2:
resolution: {integrity: sha512-+IQnCOdPiufGBkOii+Ze8F7iniyBzOwvWDbn1DyExBpc9pT2B3IEMQi7GUc/PpqhNUh/sr1SG9UXDITQoR0VIA==}
libqp@2.1.1:
resolution: {integrity: sha512-0Wd+GPz1O134cP62YU2GTOPNA7Qgl09XwCqM5zpBv87ERCXdfDtyKXvV7c9U22yWJh44QZqBocFnXN11K96qow==}
@@ -3931,6 +3945,10 @@ packages:
resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==}
engines: {node: '>= 0.4'}
on-exit-leak-free@2.1.2:
resolution: {integrity: sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==}
engines: {node: '>=14.0.0'}
on-finished@2.4.1:
resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==}
engines: {node: '>= 0.8'}
@@ -4008,6 +4026,21 @@ packages:
resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==}
engines: {node: '>=12'}
pino-abstract-transport@3.0.0:
resolution: {integrity: sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg==}
pino-std-serializers@7.1.0:
resolution: {integrity: sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==}
pino@10.3.1:
resolution: {integrity: sha512-r34yH/GlQpKZbU1BvFFqOjhISRo1MNx1tWYsYvmj6KIRHSPMT2+yHOEb1SG6NMvRoHRF0a07kCOox/9yakl1vg==}
hasBin: true
playwright-core@1.63.0:
resolution: {integrity: sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg==}
engines: {node: '>=20'}
hasBin: true
pnpm@10.18.0:
resolution: {integrity: sha512-6AT4ifHOzEDVctsITuw+SIFzn43sacD/ENLRvv+aTjCTg7ontbdQBZ1/TBSVNbbNDSyx7Trrc5I5pChKaPQM+g==}
engines: {node: '>=18.12'}
@@ -4032,6 +4065,9 @@ packages:
process-nextick-args@2.0.1:
resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==}
process-warning@5.1.0:
resolution: {integrity: sha512-jQSaVHsPgtyw60e1rQ/A+/ArPEj/S8pS/vFnyGa/gYFXrKk/6RuDkoqVDQ5NI5MmS01698ltlAk0NoDBNLujRw==}
process@0.11.10:
resolution: {integrity: sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==}
engines: {node: '>= 0.6.0'}
@@ -4054,6 +4090,9 @@ packages:
resolution: {integrity: sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg==}
engines: {node: '>=0.6'}
quick-format-unescaped@4.0.4:
resolution: {integrity: sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==}
raf@3.4.1:
resolution: {integrity: sha512-Sq4CW4QhwOHE8ucn6J34MqtZCeWFP2aQSmrlroYgqAV1PjStIhJXxYuTgUIfkEk7zTLjmIjLmU5q+fbD1NnOJA==}
@@ -4154,9 +4193,6 @@ packages:
resolution: {integrity: sha512-DGrYcCWK7tvYMnWh79yrPHt+vdx9tY+1gPZa7nJQtO/p8bLTDaHp4dzwEhQB7pZ4Xe3ok4XKuEPrVuc+wlpkmw==}
engines: {node: '>=0.10.0'}
readable-stream@1.1.14:
resolution: {integrity: sha512-+MeVjFf4L44XUkhM1eYbD8fyEsxcV81pqMSR5gblfcLCHfZvbrqy4/qYHE+/R5HoBUT11WV5O08Cr1n3YXkWVQ==}
readable-stream@2.3.8:
resolution: {integrity: sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==}
@@ -4175,6 +4211,13 @@ packages:
resolution: {integrity: sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==}
engines: {node: '>= 20.19.0'}
real-require@0.2.0:
resolution: {integrity: sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==}
engines: {node: '>= 12.13.0'}
real-require@1.0.0:
resolution: {integrity: sha512-P4nbQYQfePJxRSmY+v/KINxVucm4NF3p3s7pJveMTtom52FR4YGltUQLB8idDXwDDWW+eYrWDFbuzUnjoWHF7g==}
recharts-scale@0.4.5:
resolution: {integrity: sha512-kivNFO+0OcUNu7jQquLXAxz1FIwZj8nrj+YkOKc5694NbjCvcT6aSZiIzNzd2Kul4o4rTto8QVR9lMNtxD4G1w==}
@@ -4214,6 +4257,10 @@ packages:
safe-buffer@5.2.1:
resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==}
safe-stable-stringify@2.5.0:
resolution: {integrity: sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==}
engines: {node: '>=10'}
safer-buffer@2.1.2:
resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==}
@@ -4223,10 +4270,6 @@ packages:
selderee@0.11.0:
resolution: {integrity: sha512-5TF+l7p4+OsnP8BCCvSyZiSPc4x4//p5uPwK8TCnVPJYRmU2aYKMpOXvw8zM5a5JvuuCGN1jmsMwuU2W02ukfA==}
semver@5.3.0:
resolution: {integrity: sha512-mfmm3/H9+67MCVix1h+IXTpDwL6710LyHuk7+cWC9T1mE0qz4iHhh6r4hU2wrIT9iTsAAC2XQRvfblL028cpLw==}
hasBin: true
semver@6.3.1:
resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==}
hasBin: true
@@ -4285,6 +4328,17 @@ packages:
resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==}
engines: {node: '>=14'}
smart-buffer@4.2.0:
resolution: {integrity: sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==}
engines: {node: '>= 6.0.0', npm: '>= 3.0.0'}
socks@2.8.9:
resolution: {integrity: sha512-LJhUYUvItdQ0LkJTmPeaEObWXAqFyfmP85x0tch/ez9cahmhlBBLbIqDFnvBnUJGagb0JbIQrkBs1wJ+yRYpEw==}
engines: {node: '>= 10.0.0', npm: '>= 3.0.0'}
sonic-boom@4.2.1:
resolution: {integrity: sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==}
sonner@2.0.7:
resolution: {integrity: sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w==}
peerDependencies:
@@ -4302,6 +4356,10 @@ packages:
resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==}
engines: {node: '>=0.10.0'}
split2@4.2.0:
resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==}
engines: {node: '>= 10.x'}
sqlstring@2.3.3:
resolution: {integrity: sha512-qC9iz2FlN7DQl3+wjwn3802RTyjCx7sDvfQEXchwa6CWOx07/WVfh91gBmQ9fahw8snwGEWU3xGzOt4tFyHLxg==}
engines: {node: '>= 0.6'}
@@ -4343,9 +4401,6 @@ packages:
resolution: {integrity: sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==}
engines: {node: '>=12'}
string_decoder@0.10.31:
resolution: {integrity: sha512-ev2QzSzWPYmy9GuqfIVildA4OdcGLeFZQrq5ys6RtiuF+RQQiZWr8TZNyAcuVXyQRYfEO+MsoB/1BuQVhOJuoQ==}
string_decoder@1.1.1:
resolution: {integrity: sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==}
@@ -4402,6 +4457,10 @@ packages:
text-segmentation@1.0.3:
resolution: {integrity: sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw==}
thread-stream@4.2.0:
resolution: {integrity: sha512-e2zZ96wSChazBsbENf/Pcm/4swHt2cEKQ92rhUjkL9GCKiTDJIaTBenjE/m9DXi0QBmTMDkFDdOomUy20A1tDQ==}
engines: {node: '>=20'}
tiny-invariant@1.3.3:
resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==}
@@ -4508,9 +4567,6 @@ packages:
peerDependencies:
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
utf7@1.0.2:
resolution: {integrity: sha512-qQrPtYLLLl12NF4DrM9CvfkxkYI97xOb5dsnGZHE3teFr0tWiEZ9UdgMPczv24vl708cYMpe6mGXGHrotIp3Bw==}
util-deprecate@1.0.2:
resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
@@ -5883,6 +5939,8 @@ snapshots:
dependencies:
pako: 1.0.11
'@pinojs/redact@0.4.0': {}
'@pkgjs/parseargs@0.11.0':
optional: true
@@ -7136,10 +7194,6 @@ snapshots:
'@types/http-errors@2.0.5': {}
'@types/imap@0.8.43':
dependencies:
'@types/node': 24.7.0
'@types/jsonwebtoken@9.0.10':
dependencies:
'@types/ms': 2.1.0
@@ -7269,6 +7323,12 @@ snapshots:
loupe: 3.2.1
tinyrainbow: 1.2.0
'@zone-eu/mailsplit@5.4.15':
dependencies:
libbase64: 1.3.0
libmime: 5.4.2
libqp: 2.1.1
'@zone-eu/mailsplit@5.4.8':
dependencies:
libbase64: 1.3.0
@@ -7338,6 +7398,8 @@ snapshots:
asynckit@0.4.0: {}
atomic-sleep@1.0.0: {}
autoprefixer@10.4.21(postcss@8.5.6):
dependencies:
browserslist: 4.26.3
@@ -8118,12 +8180,22 @@ snapshots:
dependencies:
safer-buffer: 2.1.2
iconv-lite@0.7.3:
dependencies:
safer-buffer: 2.1.2
ieee754@1.2.1: {}
imap@0.8.19:
imapflow@1.7.2:
dependencies:
readable-stream: 1.1.14
utf7: 1.0.2
'@zone-eu/mailsplit': 5.4.15
encoding-japanese: 2.2.0
iconv-lite: 0.7.3
libbase64: 1.3.0
libmime: 5.4.2
libqp: 2.1.1
pino: 10.3.1
socks: 2.8.9
inherits@2.0.4: {}
@@ -8136,6 +8208,8 @@ snapshots:
iobuffer@5.4.0: {}
ip-address@10.5.0: {}
ipaddr.js@1.9.1: {}
is-docker@3.0.0: {}
@@ -8156,8 +8230,6 @@ snapshots:
dependencies:
is-inside-container: 1.0.0
isarray@0.0.1: {}
isarray@1.0.0: {}
isexe@2.0.0: {}
@@ -8232,6 +8304,13 @@ snapshots:
libbase64: 1.3.0
libqp: 2.1.1
libmime@5.4.2:
dependencies:
encoding-japanese: 2.2.0
iconv-lite: 0.7.3
libbase64: 1.3.0
libqp: 2.1.1
libqp@2.1.1: {}
lightningcss-darwin-arm64@1.30.1:
@@ -8439,6 +8518,8 @@ snapshots:
object-inspect@1.13.4: {}
on-exit-leak-free@2.1.2: {}
on-finished@2.4.1:
dependencies:
ee-first: 1.1.1
@@ -8508,6 +8589,28 @@ snapshots:
picomatch@4.0.3: {}
pino-abstract-transport@3.0.0:
dependencies:
split2: 4.2.0
pino-std-serializers@7.1.0: {}
pino@10.3.1:
dependencies:
'@pinojs/redact': 0.4.0
atomic-sleep: 1.0.0
on-exit-leak-free: 2.1.2
pino-abstract-transport: 3.0.0
pino-std-serializers: 7.1.0
process-warning: 5.1.0
quick-format-unescaped: 4.0.4
real-require: 0.2.0
safe-stable-stringify: 2.5.0
sonic-boom: 4.2.1
thread-stream: 4.2.0
playwright-core@1.63.0: {}
pnpm@10.18.0: {}
postcss-selector-parser@6.0.10:
@@ -8527,6 +8630,8 @@ snapshots:
process-nextick-args@2.0.1: {}
process-warning@5.1.0: {}
process@0.11.10: {}
prop-types@15.8.1:
@@ -8548,6 +8653,8 @@ snapshots:
dependencies:
side-channel: 1.1.0
quick-format-unescaped@4.0.4: {}
raf@3.4.1:
dependencies:
performance-now: 2.1.0
@@ -8650,13 +8757,6 @@ snapshots:
react@19.2.1: {}
readable-stream@1.1.14:
dependencies:
core-util-is: 1.0.3
inherits: 2.0.4
isarray: 0.0.1
string_decoder: 0.10.31
readable-stream@2.3.8:
dependencies:
core-util-is: 1.0.3
@@ -8687,6 +8787,10 @@ snapshots:
readdirp@5.0.0: {}
real-require@0.2.0: {}
real-require@1.0.0: {}
recharts-scale@0.4.5:
dependencies:
decimal.js-light: 2.5.1
@@ -8748,6 +8852,8 @@ snapshots:
safe-buffer@5.2.1: {}
safe-stable-stringify@2.5.0: {}
safer-buffer@2.1.2: {}
scheduler@0.27.0: {}
@@ -8756,8 +8862,6 @@ snapshots:
dependencies:
parseley: 0.12.1
semver@5.3.0: {}
semver@6.3.1: {}
semver@7.7.3: {}
@@ -8862,6 +8966,17 @@ snapshots:
signal-exit@4.1.0: {}
smart-buffer@4.2.0: {}
socks@2.8.9:
dependencies:
ip-address: 10.5.0
smart-buffer: 4.2.0
sonic-boom@4.2.1:
dependencies:
atomic-sleep: 1.0.0
sonner@2.0.7(react-dom@19.2.1(react@19.2.1))(react@19.2.1):
dependencies:
react: 19.2.1
@@ -8876,6 +8991,8 @@ snapshots:
source-map@0.6.1: {}
split2@4.2.0: {}
sqlstring@2.3.3: {}
ssf@0.11.2:
@@ -8925,8 +9042,6 @@ snapshots:
emoji-regex: 9.2.2
strip-ansi: 7.2.0
string_decoder@0.10.31: {}
string_decoder@1.1.1:
dependencies:
safe-buffer: 5.1.2
@@ -8999,6 +9114,10 @@ snapshots:
utrie: 1.0.2
optional: true
thread-stream@4.2.0:
dependencies:
real-require: 1.0.0
tiny-invariant@1.3.3: {}
tinybench@2.9.0: {}
@@ -9077,10 +9196,6 @@ snapshots:
dependencies:
react: 19.2.1
utf7@1.0.2:
dependencies:
semver: 5.3.0
util-deprecate@1.0.2: {}
utils-merge@1.0.1: {}

View File

@@ -0,0 +1,31 @@
#!/bin/sh
# Refuse de lancer l'application si le répertoire PDF attendu n'est pas un
# volume Docker réellement monté. Cette barrière évite de démarrer avec un
# dossier de conteneur vide après une reconstruction.
set -eu
storage_path="${STORAGE_BASE_PATH:-/app/storage}"
require_storage_volume="${REQUIRE_STORAGE_VOLUME:-false}"
if [ "$require_storage_volume" = "true" ]; then
if [ ! -d "$storage_path" ]; then
echo "[StorageGuard] Répertoire de stockage absent : $storage_path" >&2
exit 1
fi
if ! awk -v expected="$storage_path" '$2 == expected { mounted = 1 } END { exit mounted ? 0 : 1 }' /proc/mounts; then
echo "[StorageGuard] Volume persistant non monté sur $storage_path ; démarrage annulé." >&2
exit 1
fi
if [ ! -w "$storage_path" ]; then
echo "[StorageGuard] Volume persistant non inscriptible : $storage_path" >&2
exit 1
fi
else
# L’hébergement cloud n’expose pas de volume Docker local. Il utilise le
# stockage applicatif disponible à l’exécution et doit donc pouvoir démarrer.
mkdir -p "$storage_path"
fi
exec node dist/index.js

View File

@@ -0,0 +1,70 @@
import { ImapFlow } from "imapflow";
import mysql from "mysql2/promise";
const databaseUrl = process.env.DATABASE_URL;
if (!databaseUrl) throw new Error("Variable DATABASE_URL manquante");
const connection = await mysql.createConnection(databaseUrl);
try {
const [rows] = await connection.query(`
SELECT emailImportAddress, emailImportHost, emailImportPort,
azureTenantId, azureClientId, azureClientSecret
FROM importSettings
WHERE emailImportEnabled = 1
AND emailImportAuthMode = 'oauth2'
ORDER BY id
LIMIT 1
`);
const settings = rows[0];
if (!settings) throw new Error("Aucune configuration OAuth2 IMAP active");
const tenantId = settings.azureTenantId || process.env.AZURE_AD_TENANT_ID;
const clientId = settings.azureClientId || process.env.AZURE_AD_CLIENT_ID;
const clientSecret = settings.azureClientSecret || process.env.AZURE_AD_CLIENT_SECRET;
if (!tenantId || !clientId || !clientSecret) {
throw new Error("Configuration Azure AD incomplète");
}
const response = await fetch(
`https://login.microsoftonline.com/${tenantId}/oauth2/v2.0/token`,
{
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
client_id: clientId,
client_secret: clientSecret,
scope: "https://outlook.office365.com/.default",
grant_type: "client_credentials",
}),
},
);
const tokenResponse = await response.json();
if (!response.ok || !tokenResponse.access_token) {
throw new Error(`Échec OAuth2 : ${tokenResponse.error || response.status}`);
}
const host = settings.emailImportHost || "outlook.office365.com";
const client = new ImapFlow({
host,
port: settings.emailImportPort || 993,
secure: true,
auth: {
user: settings.emailImportAddress,
accessToken: tokenResponse.access_token,
},
tls: { servername: host, rejectUnauthorized: true },
verifyOnly: true,
logger: false,
});
try {
await client.connect();
console.log(`Authentification ImapFlow OAuth2 réussie pour ${settings.emailImportAddress}`);
} finally {
if (client.usable) await client.logout().catch(() => client.close());
else client.close();
}
} finally {
await connection.end();
}

View File

@@ -0,0 +1,49 @@
import { ImapFlow } from "imapflow";
const required = (name) => {
const value = process.env[name];
if (!value) throw new Error(`Variable ${name} manquante`);
return value;
};
const tenantId = required("AZURE_AD_TENANT_ID");
const clientId = required("AZURE_AD_CLIENT_ID");
const clientSecret = required("AZURE_AD_CLIENT_SECRET");
const email = required("IMAP_TEST_EMAIL");
const host = process.env.IMAP_TEST_HOST || "outlook.office365.com";
const response = await fetch(
`https://login.microsoftonline.com/${tenantId}/oauth2/v2.0/token`,
{
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
client_id: clientId,
client_secret: clientSecret,
scope: "https://outlook.office365.com/.default",
grant_type: "client_credentials",
}),
},
);
const tokenResponse = await response.json();
if (!response.ok || !tokenResponse.access_token) {
throw new Error(`Échec OAuth2 : ${tokenResponse.error || response.status}`);
}
const client = new ImapFlow({
host,
port: 993,
secure: true,
auth: { user: email, accessToken: tokenResponse.access_token },
tls: { servername: host, rejectUnauthorized: true },
verifyOnly: true,
logger: false,
});
try {
await client.connect();
console.log(`Authentification ImapFlow OAuth2 réussie pour ${email}`);
} finally {
if (client.usable) await client.logout().catch(() => client.close());
else client.close();
}

View File

@@ -23,7 +23,7 @@ Contenu de `config.json` :
{
"sfrLogin": "votre-login@sfr.fr",
"sfrPassword": "votre-mot-de-passe",
"appUrl": "https://demat-facturation.santinova-soft.org",
"appUrl": "https://demat-facturation-santinova.santinova-soft.org",
"apiToken": "votre-token-api-copié-depuis-lappli",
"downloadDir": "/tmp/sfr-invoices",
"processedFile": "/tmp/sfr-processed.json"
@@ -34,7 +34,7 @@ Ou utiliser des variables d'environnement :
```bash
export SFR_LOGIN=votre-login@sfr.fr
export SFR_PASSWORD=votre-mot-de-passe
export APP_URL=https://demat-facturation.santinova-soft.org
export APP_URL=https://demat-facturation-santinova.santinova-soft.org
export API_TOKEN=votre-token-api
```

View File

@@ -1,16 +1,16 @@
/**
* Connecteur SFR Pro - Script cron pour import automatique de factures
*
*
* Prérequis sur le serveur LWS :
* npm install playwright @playwright/test
* npx playwright install chromium
*
*
* Configuration :
* Copier .env.example en .env et remplir les variables
*
*
* Utilisation :
* node sfr-connector.mjs
*
*
* Cron (mensuel le 5 du mois à 8h) :
* 0 8 5 * * /usr/bin/node /opt/web-import/sfr-connector.mjs >> /var/log/sfr-import.log 2>&1
*/
@@ -33,7 +33,7 @@ const CONFIG = {
login: process.env.SFR_LOGIN || '',
password: process.env.SFR_PASSWORD || '',
// URL de l'application de dématérialisation
appUrl: process.env.APP_URL || 'https://demat-facturation.santinova-soft.org',
appUrl: process.env.APP_URL || 'https://demat-facturation-santinova.santinova-soft.org',
// Token API de la source web (récupéré depuis l'interface Connecteurs web)
apiToken: process.env.API_TOKEN || '',
// Dossier temporaire pour les PDFs téléchargés
@@ -63,7 +63,7 @@ function saveProcessed(list) {
async function pushInvoiceToApp(filePath, fileName) {
const fileBuffer = fs.readFileSync(filePath);
const fileBase64 = fileBuffer.toString('base64');
const body = JSON.stringify({
apiToken: CONFIG.apiToken,
fileName,
@@ -104,7 +104,7 @@ async function pushInvoiceToApp(filePath, fileName) {
// ============ CONNECTEUR SFR ============
async function runSfrConnector() {
log('Démarrage du connecteur SFR Pro');
if (!CONFIG.login || !CONFIG.password || !CONFIG.apiToken) {
log('ERREUR : SFR_LOGIN, SFR_PASSWORD et API_TOKEN sont requis');
process.exit(1);
@@ -140,14 +140,14 @@ async function runSfrConnector() {
await page.fill('input[type="email"], input[name="login"], input[id*="login"], input[id*="email"]', CONFIG.login);
await page.fill('input[type="password"], input[name="password"], input[id*="password"]', CONFIG.password);
await page.click('button[type="submit"], input[type="submit"], button:has-text("Connexion"), button:has-text("Se connecter")');
await page.waitForNavigation({ waitUntil: 'networkidle', timeout: 15000 }).catch(() => {});
log('Connecté');
// 4. Naviguer vers la section factures
// Adapter selon la structure réelle du site SFR Pro
await page.goto(`${CONFIG.portalUrl}factures`, { waitUntil: 'networkidle', timeout: 15000 }).catch(() => {});
// Chercher les liens de factures PDF
const invoiceLinks = await page.$$eval(
'a[href*=".pdf"], a[href*="facture"], a[href*="invoice"], a[download]',

View File

@@ -1,3 +1,21 @@
import { readFileSync } from "node:fs";
/** Lit un secret monté par Docker, sans jamais exposer son contenu aux logs. */
export function readSecretFile(path: string | undefined): string {
if (!path) return "";
try {
return readFileSync(path, "utf8").trim();
} catch {
return "";
}
}
/** Préfère un secret explicite, puis le secret Docker si la variable est absente ou vide. */
export function resolveServerSecret(value: string | undefined, secretPath: string | undefined): string {
const environmentValue = value?.trim();
return environmentValue || readSecretFile(secretPath);
}
export const ENV = {
appId: process.env.VITE_APP_ID ?? "",
cookieSecret: process.env.JWT_SECRET ?? "",
@@ -9,4 +27,9 @@ export const ENV = {
forgeApiKey: process.env.BUILT_IN_FORGE_API_KEY ?? "",
mistralApiKey: process.env.MISTRAL_API_KEY ?? "",
geminiApiKey: process.env.GEMINI_API_KEY ?? "",
referentielIaUrl: process.env.REFERENTIEL_IA_URL ?? "",
referentielIaToken: resolveServerSecret(
process.env.REFERENTIEL_IA_TOKEN,
process.env.REFERENTIEL_IA_TOKEN_FILE,
),
};

View File

@@ -17,7 +17,9 @@ import { startEmailImportService } from "../emailImportService";
import { startFolderImportService } from "../folderImportService";
import { handleAzureCallback, isAzureAdConfigured, generateToken, verifyToken } from "../auth";
import { createDatabaseBackup } from "../databaseBackup";
import { generateStorageKey, localStoragePut } from "../localStorage";
import { generateStorageKey, localStorageDelete, localStoragePut } from "../localStorage";
import { calculateFileSha256 } from "../fileFingerprint";
import { withCalculatedInvoiceQuality } from "@shared/invoiceQuality";
const MAX_WEB_IMPORT_BYTES = 20 * 1024 * 1024;
@@ -305,7 +307,7 @@ async function startServer() {
return;
}
const { getWebImportSourceByToken, createInvoice, findDuplicateInvoice, isInvoiceBlacklisted, updateWebImportSourceStatus, createSourceFile } = await import('../db');
const { getWebImportSourceByToken, createInvoice, findDuplicateInvoice, updateWebImportSourceStatus, createSourceFile, getSourceFileByContentHash, getLlmFieldsConfigByUser } = await import('../db');
const source = await getWebImportSourceByToken(apiToken);
if (!source) {
res.status(401).json({ error: "Token invalide" });
@@ -317,16 +319,37 @@ async function startServer() {
return;
}
const contentHash = calculateFileSha256(pdfBuffer);
const existingSource = await getSourceFileByContentHash(contentHash);
if (existingSource) {
await updateWebImportSourceStatus(source.id, "success", 0, true);
res.json({ success: true, imported: 0, duplicates: 1, total: 1 });
return;
}
// Stocker d'abord le PDF de façon persistante, comme les autres sources d'import.
const storageKey = generateStorageKey(source.userId, safeFileName);
const { url: fileUrl } = await localStoragePut(storageKey, pdfBuffer, "application/pdf");
const sourceFile = await createSourceFile({
userId: source.userId,
fileName: safeFileName,
fileKey: storageKey,
fileUrl,
});
let sourceFile;
try {
sourceFile = await createSourceFile({
userId: source.userId,
fileName: safeFileName,
fileKey: storageKey,
fileUrl,
contentHash,
});
} catch (error: any) {
if (error?.code === "ER_DUP_ENTRY" || error?.errno === 1062) {
await localStorageDelete(storageKey).catch(() => undefined);
await updateWebImportSourceStatus(source.id, "success", 0, true);
res.json({ success: true, imported: 0, duplicates: 1, total: 1 });
return;
}
throw error;
}
const userSettings = await getUserSettings(source.userId);
const qualityFieldsConfig = await getLlmFieldsConfigByUser(source.userId);
const aiSettings = {
aiProvider: userSettings?.aiProvider || "manus",
mistralApiKey: userSettings?.mistralApiKey || undefined,
@@ -339,11 +362,10 @@ async function startServer() {
let imported = 0;
let duplicates = 0;
for (const inv of extractResult.invoices || []) {
const blacklisted = await isInvoiceBlacklisted(inv.invoiceNumber || null, source.userId);
if (blacklisted) { duplicates++; continue; }
const dup = await findDuplicateInvoice(inv.invoiceNumber || null, String(inv.totalAmount ?? ''), source.userId);
if (dup) { duplicates++; continue; }
await createInvoice({ ...inv, userId: source.userId, sourceFileId: sourceFile.id } as any);
const invoiceWithQuality = withCalculatedInvoiceQuality(inv, qualityFieldsConfig);
await createInvoice({ ...invoiceWithQuality, userId: source.userId, sourceFileId: sourceFile.id } as any);
imported++;
}
await updateWebImportSourceStatus(source.id, 'success', imported, true);

View File

@@ -1,4 +1,5 @@
import { ENV } from "./env";
import { invokeReferentialIa } from "../referentialIa";
export type Role = "system" | "user" | "assistant" | "tool" | "function";
@@ -97,6 +98,29 @@ export type InvokeResult = {
};
};
export type LlmProvider = "mistral" | "manus" | "gemini" | "referentiel";
export type LlmTransport = "mistral-api" | "manus-forge" | "gemini-api" | "referentiel-gateway";
export type UserLlmSettings = {
aiProvider?: string | null;
mistralApiKey?: string | null;
manusForgeApiKey?: string | null;
manusForgeApiUrl?: string | null;
geminiApiKey?: string | null;
};
/** Métadonnées non sensibles décrivant l’appel LLM réellement résolu. */
export type ResolvedLlmTrace = {
provider: LlmProvider;
model: string;
transport: LlmTransport;
};
export type ResolvedLlmInvocation = {
response: InvokeResult;
trace: ResolvedLlmTrace;
};
export type JsonSchema = {
name: string;
schema: Record<string, unknown>;
@@ -283,7 +307,7 @@ const normalizeResponseFormat = ({
/**
* Résout l'URL API en tenant compte des paramètres utilisateur (DB) en priorité sur les variables d'environnement.
*/
function resolveApiUrlWithSettings(settings?: { aiProvider?: string | null; mistralApiKey?: string | null; manusForgeApiKey?: string | null; manusForgeApiUrl?: string | null; geminiApiKey?: string | null }): string {
function resolveApiUrlWithSettings(settings?: UserLlmSettings): string {
const provider = settings?.aiProvider || "mistral";
if (provider === "mistral") {
return "https://api.mistral.ai/v1/chat/completions";
@@ -297,7 +321,7 @@ function resolveApiUrlWithSettings(settings?: { aiProvider?: string | null; mist
return `${forgeUrl.replace(/\/$/, "")}/v1/chat/completions`;
}
function getApiKeyWithSettings(settings?: { aiProvider?: string | null; mistralApiKey?: string | null; manusForgeApiKey?: string | null; geminiApiKey?: string | null }): string {
function getApiKeyWithSettings(settings?: UserLlmSettings): string {
const provider = settings?.aiProvider || "mistral";
if (provider === "mistral") {
return settings?.mistralApiKey || ENV.mistralApiKey || "";
@@ -308,11 +332,23 @@ function getApiKeyWithSettings(settings?: { aiProvider?: string | null; mistralA
return settings?.manusForgeApiKey || ENV.forgeApiKey || "";
}
function getModelForProvider(provider: string): string {
if (provider === "mistral") return "mistral-large-latest";
if (provider === "gemini") return "gemini-2.0-flash";
// Manus Forge → Gemini 2.5 Flash via proxy
return "gemini-2.5-flash";
/**
* Résout le fournisseur, le modèle et la passerelle effectivement utilisés.
* Cette trace ne contient aucun secret ni URL personnalisée.
*/
export function resolveLlmTrace(settings?: UserLlmSettings): ResolvedLlmTrace {
const provider = settings?.aiProvider || "mistral";
if (provider === "manus") {
return { provider: "manus", model: "gemini-2.5-flash", transport: "manus-forge" };
}
if (provider === "gemini") {
return { provider: "gemini", model: "gemini-2.0-flash", transport: "gemini-api" };
}
if (provider === "referentiel") {
return { provider: "referentiel", model: "referentiel-ia", transport: "referentiel-gateway" };
}
return { provider: "mistral", model: "mistral-large-latest", transport: "mistral-api" };
}
/**
@@ -321,15 +357,19 @@ function getModelForProvider(provider: string): string {
*/
export async function invokeLLMWithUserSettings(
params: InvokeParams,
userSettings?: { aiProvider?: string | null; mistralApiKey?: string | null; manusForgeApiKey?: string | null; manusForgeApiUrl?: string | null; geminiApiKey?: string | null }
): Promise<InvokeResult> {
userSettings?: UserLlmSettings
): Promise<ResolvedLlmInvocation> {
const trace = resolveLlmTrace(userSettings);
if (trace.provider === "referentiel") {
return { response: await invokeReferentialIa(params), trace };
}
const apiKey = getApiKeyWithSettings(userSettings);
if (!apiKey || apiKey.trim().length === 0) {
throw new Error("Aucune clé API configurée pour le moteur IA sélectionné");
}
const provider = userSettings?.aiProvider || "mistral";
const provider = trace.provider;
const {
messages,
tools,
@@ -341,7 +381,7 @@ export async function invokeLLMWithUserSettings(
response_format,
} = params;
const model = getModelForProvider(provider);
const model = trace.model;
const payload: Record<string, unknown> = {
model,
@@ -378,7 +418,10 @@ export async function invokeLLMWithUserSettings(
throw new Error(`LLM invoke failed: ${response.status} ${response.statusText} – ${errorText}`);
}
return (await response.json()) as InvokeResult;
return {
response: (await response.json()) as InvokeResult,
trace,
};
}
export async function invokeLLM(params: InvokeParams): Promise<InvokeResult> {

View File

@@ -3,10 +3,18 @@ import fs from "fs";
import { type Server } from "http";
import { nanoid } from "nanoid";
import path from "path";
import { createServer as createViteServer } from "vite";
import viteConfig from "../../vite.config";
export async function setupVite(app: Express, server: Server) {
// Vite et sa configuration sont des dépendances de développement. Les imports
// indirects empêchent esbuild de les intégrer au bundle serveur de production.
const vitePackageName = "vite";
const viteConfigPath = "../../vite.config";
const [viteModule, viteConfigModule] = await Promise.all([
import(vitePackageName),
import(viteConfigPath),
]);
const createViteServer = viteModule.createServer as typeof import("vite").createServer;
const viteConfig = viteConfigModule.default;
const serverOptions = {
middlewareMode: true,
hmr: { server },

View File

@@ -0,0 +1,30 @@
import { describe, expect, it } from "vitest";
import { matchesAutomationActionFilter } from "@shared/automationActions";
describe("matchesAutomationActionFilter", () => {
it("filtre chaque type d’action métier", () => {
const actions = JSON.stringify({
typeAchat: "OPEX",
serviceConcerne: "DSI SANTINOVA",
ventilationComptable: "SANTINOVA",
});
expect(matchesAutomationActionFilter(actions, "typeAchat")).toBe(true);
expect(matchesAutomationActionFilter(actions, "serviceConcerne")).toBe(true);
expect(matchesAutomationActionFilter(actions, "ventilationComptable")).toBe(true);
expect(matchesAutomationActionFilter(actions, "subscription")).toBe(false);
});
it("distingue les règles Abonnement Oui et Non", () => {
expect(matchesAutomationActionFilter('{"isSubscription":1}', "subscription")).toBe(true);
expect(matchesAutomationActionFilter('{"isSubscription":1}', "subscriptionYes")).toBe(true);
expect(matchesAutomationActionFilter('{"isSubscription":1}', "subscriptionNo")).toBe(false);
expect(matchesAutomationActionFilter('{"isSubscription":0}', "subscriptionYes")).toBe(false);
expect(matchesAutomationActionFilter('{"isSubscription":0}', "subscriptionNo")).toBe(true);
});
it("tolère les règles historiques invalides sans masquer le filtre Toutes les actions", () => {
expect(matchesAutomationActionFilter("json-invalide", "all")).toBe(true);
expect(matchesAutomationActionFilter("json-invalide", "subscription")).toBe(false);
});
});

View File

@@ -0,0 +1,66 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { Invoice } from "../drizzle/schema";
import { getAutomationRulesByUser } from "./db";
import { applyAutomationRules } from "./automationEngine";
vi.mock("./db", () => ({
getAutomationRulesByUser: vi.fn(),
}));
const mockedGetAutomationRules = vi.mocked(getAutomationRulesByUser);
const invoice = {
id: 1,
supplierName: "Microsoft Ireland Operations Ltd",
isSubscription: 0,
} as Invoice;
describe("applyAutomationRules", () => {
beforeEach(() => {
mockedGetAutomationRules.mockReset();
});
it("applique Abonnement = Oui et trace le champ automatisé", async () => {
mockedGetAutomationRules.mockResolvedValue([
{
id: 1,
userId: 1,
name: "Microsoft est un abonnement",
isActive: 1,
priority: 1,
conditions: JSON.stringify([{ field: "supplierName", operator: "contains", value: "microsoft" }]),
conditionsLogic: "AND",
actions: JSON.stringify({ isSubscription: 1 }),
createdAt: new Date(),
updatedAt: new Date(),
},
] as never);
await expect(applyAutomationRules(1, invoice)).resolves.toMatchObject({
isSubscription: 1,
autoFilledFields: JSON.stringify(["isSubscription"]),
});
});
it("autorise Abonnement = Non sans le confondre avec une absence d’action", async () => {
mockedGetAutomationRules.mockResolvedValue([
{
id: 2,
userId: 1,
name: "Microsoft n’est pas un abonnement",
isActive: 1,
priority: 1,
conditions: JSON.stringify([{ field: "supplierName", operator: "contains", value: "microsoft" }]),
conditionsLogic: "AND",
actions: JSON.stringify({ isSubscription: 0 }),
createdAt: new Date(),
updatedAt: new Date(),
},
] as never);
await expect(applyAutomationRules(1, invoice)).resolves.toMatchObject({
isSubscription: 0,
autoFilledFields: JSON.stringify(["isSubscription"]),
});
});
});

View File

@@ -11,6 +11,7 @@ interface Actions {
typeAchat?: string;
serviceConcerne?: string;
ventilationComptable?: string;
isSubscription?: 0 | 1;
}
/**
@@ -111,6 +112,11 @@ export async function applyAutomationRules(
updates.ventilationComptable = actions.ventilationComptable;
autoFilledFieldsList.push("ventilationComptable");
}
// 0 est une valeur métier valide : ne jamais la tester par vérité.
if (actions.isSubscription !== undefined && updates.isSubscription === undefined) {
updates.isSubscription = actions.isSubscription;
autoFilledFieldsList.push("isSubscription");
}
}
} catch (error) {
console.error(`[AutomationEngine] Error processing rule ${rule.id}:`, error);

View File

@@ -0,0 +1,16 @@
import { describe, expect, it } from "vitest";
import { BAP_MIN_QUALITY_SCORE, meetsBapQualityThreshold } from "@shared/bapEligibility";
describe("meetsBapQualityThreshold", () => {
it("autorise exactement le seuil de 90 %", () => {
expect(BAP_MIN_QUALITY_SCORE).toBe(90);
expect(meetsBapQualityThreshold(90)).toBe(true);
expect(meetsBapQualityThreshold(100)).toBe(true);
});
it("refuse les scores inférieurs ou absents", () => {
expect(meetsBapQualityThreshold(89)).toBe(false);
expect(meetsBapQualityThreshold(null)).toBe(false);
expect(meetsBapQualityThreshold(undefined)).toBe(false);
});
});

View File

@@ -0,0 +1,40 @@
import { describe, expect, it } from "vitest";
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { requiresBapBeforeOriginalPdfExport } from "../shared/bapExportGuard";
describe("garde-fou d’export avant BAP", () => {
it("bloque l’export PDF original d’une facture hors abonnement non validée BAP", () => {
expect(requiresBapBeforeOriginalPdfExport({ isSubscription: 0, bapValidated: 0 })).toBe(true);
});
it("autorise les abonnements et les factures déjà validées BAP", () => {
expect(requiresBapBeforeOriginalPdfExport({ isSubscription: 1, bapValidated: 0 })).toBe(false);
expect(requiresBapBeforeOriginalPdfExport({ isSubscription: 0, bapValidated: 1 })).toBe(false);
});
it("applique le garde-fou aux deux exports PDF métier côté serveur", () => {
const routerSource = readFileSync(join(process.cwd(), "server", "routers.ts"), "utf8");
const exportPdfSection = routerSource.slice(
routerSource.indexOf("exportToPdf: protectedProcedure"),
routerSource.indexOf("exportInvoices: protectedProcedure"),
);
const exportInvoicesSection = routerSource.slice(
routerSource.indexOf("exportInvoices: protectedProcedure"),
routerSource.indexOf("// ============= IMPORT LOGS ROUTES"),
);
expect(exportPdfSection).toContain("requiresBapBeforeOriginalPdfExport");
expect(exportInvoicesSection).toContain("requiresBapBeforeOriginalPdfExport");
});
it("clarifie les exports historiques et autorise leur régularisation BAP dans l’interface", () => {
const invoicesPage = readFileSync(join(process.cwd(), "client", "src", "pages", "Invoices.tsx"), "utf8");
const bapPage = readFileSync(join(process.cwd(), "client", "src", "pages", "InvoicesBAP.tsx"), "utf8");
expect(invoicesPage).toContain("requiresBapBeforeOriginalPdfExport");
expect(bapPage).toContain("Exporté avant BAP");
expect(bapPage).toContain("wasExportedBeforeBap");
expect(bapPage).not.toContain('invoice.exportStatus !== "exported"');
});
});

View File

@@ -0,0 +1,61 @@
import { describe, expect, it } from "vitest";
import {
buildBapDetailLocation,
getBapReturnLocation,
parseBapFilters,
} from "../client/src/lib/bapNavigation";
describe("navigation Factures BAP", () => {
it("transmet tous les filtres et le tri à la page de détail", () => {
const location = buildBapDetailLocation(42, {
searchQuery: "microsoft europe",
statusFilter: "bap_pending",
selectedYear: "2026",
selectedMonth: "05",
recipientFilter: "Direction générale",
entityFilter: "santinova",
ventilationFilter: "615200",
sortField: "invoiceDate",
sortDir: "asc",
});
expect(location).toBe(
"/invoices/42?returnTo=bap&q=microsoft+europe&status=bap_pending&year=2026&month=05&recipient=Direction+g%C3%A9n%C3%A9rale&entity=santinova&ventilation=615200&sort=invoiceDate&dir=asc"
);
});
it("restaure la liste BAP et les filtres de façon sûre", () => {
const detailSearch =
"?returnTo=bap&q=microsoft+europe&status=bap_pending&year=2026&month=05&recipient=Direction+g%C3%A9n%C3%A9rale&entity=santinova&ventilation=615200&sort=invoiceDate&dir=asc";
expect(getBapReturnLocation(detailSearch)).toBe(
"/invoices-bap?q=microsoft+europe&status=bap_pending&year=2026&month=05&recipient=Direction+g%C3%A9n%C3%A9rale&entity=santinova&ventilation=615200&sort=invoiceDate&dir=asc"
);
expect(parseBapFilters(detailSearch, "2026")).toEqual({
searchQuery: "microsoft europe",
statusFilter: "bap_pending",
selectedYear: "2026",
selectedMonth: "05",
recipientFilter: "Direction générale",
entityFilter: "santinova",
ventilationFilter: "615200",
sortField: "invoiceDate",
sortDir: "asc",
});
});
it("retombe sur les valeurs BAP sûres si les paramètres sont absents ou invalides", () => {
expect(parseBapFilters("?returnTo=bap&status=invalid&year=x&sort=nope&dir=down", "2026")).toEqual({
searchQuery: "",
statusFilter: "all",
selectedYear: "2026",
selectedMonth: "all",
recipientFilter: "all",
entityFilter: "all",
ventilationFilter: "all",
sortField: "createdAt",
sortDir: "desc",
});
expect(getBapReturnLocation("?returnTo=invoices")).toBe("/invoices");
});
});

View File

@@ -1,4 +1,4 @@
import { eq, and, desc, sql, inArray } from "drizzle-orm";
import { eq, and, desc, sql, inArray, getTableColumns, count, lt } from "drizzle-orm";
import { drizzle } from "drizzle-orm/mysql2";
import {
InsertUser,
@@ -30,6 +30,9 @@ import {
automationRules,
InsertAutomationRule,
AutomationRule,
exportAutomationRules,
InsertExportAutomationRule,
ExportAutomationRule,
llmFieldsConfig,
InsertLlmFieldConfig,
LlmFieldConfig,
@@ -50,6 +53,7 @@ import {
WebImportSource
} from "../drizzle/schema";
import { ENV } from './_core/env';
import { buildAnnualInvoiceSummary } from "@shared/invoiceAnalytics";
let _db: ReturnType<typeof drizzle> | null = null;
@@ -204,6 +208,22 @@ export async function createSourceFile(data: InsertSourceFile): Promise<SourceFi
return inserted[0]!;
}
/**
* Recherche un PDF déjà ingéré, quel que soit le compte utilisateur.
* L'import email est partagé entre plusieurs identités : la détection doit donc
* être globale pour éviter qu'une même pièce soit retraitée sous chaque compte.
*/
export async function getSourceFileByContentHash(contentHash: string): Promise<SourceFile | undefined> {
const db = await getDb();
if (!db) return undefined;
const result = await db
.select()
.from(sourceFiles)
.where(eq(sourceFiles.contentHash, contentHash))
.limit(1);
return result[0];
}
export async function getSourceFileById(id: number): Promise<SourceFile | undefined> {
const db = await getDb();
if (!db) return undefined;
@@ -211,6 +231,17 @@ export async function getSourceFileById(id: number): Promise<SourceFile | undefi
return result[0];
}
/** Nombre de factures encore rattachées à une source PDF. */
export async function countInvoicesBySourceFileId(sourceFileId: number): Promise<number> {
const db = await getDb();
if (!db) return 0;
const result = await db
.select({ value: count() })
.from(invoices)
.where(eq(invoices.sourceFileId, sourceFileId));
return Number(result[0]?.value ?? 0);
}
export async function updateSourceFile(id: number, data: Partial<SourceFile>) {
const db = await getDb();
if (!db) throw new Error("Database not available");
@@ -271,12 +302,26 @@ export async function deleteInvoice(id: number) {
// Enregistrer dans la blacklist avant suppression
const invoice = await db.select().from(invoices).where(eq(invoices.id, id)).limit(1);
if (invoice[0] && invoice[0].invoiceNumber) {
await db.insert(deletedInvoices).values({
userId: invoice[0].userId,
invoiceNumber: invoice[0].invoiceNumber,
totalAmount: invoice[0].totalAmount ?? undefined,
supplierName: invoice[0].supplierName ?? undefined,
}).onDuplicateKeyUpdate({ set: { deletedAt: new Date() } });
const blacklistEntry = await db.select({ id: deletedInvoices.id })
.from(deletedInvoices)
.where(and(
eq(deletedInvoices.userId, invoice[0].userId),
eq(deletedInvoices.invoiceNumber, invoice[0].invoiceNumber),
))
.limit(1);
if (blacklistEntry[0]) {
await db.update(deletedInvoices)
.set({ deletedAt: new Date() })
.where(eq(deletedInvoices.id, blacklistEntry[0].id));
} else {
await db.insert(deletedInvoices).values({
userId: invoice[0].userId,
invoiceNumber: invoice[0].invoiceNumber,
totalAmount: invoice[0].totalAmount ?? undefined,
supplierName: invoice[0].supplierName ?? undefined,
});
}
}
await db.delete(invoices).where(eq(invoices.id, id));
}
@@ -316,7 +361,7 @@ export async function searchInvoices(userId: number | null, query: string): Prom
.orderBy(desc(invoices.createdAt));
}
export async function getInvoiceStats(userId: number) {
export async function getInvoiceStats(userId?: number) {
const db = await getDb();
if (!db) return {
total: 0,
@@ -330,10 +375,12 @@ export async function getInvoiceStats(userId: number) {
topSuppliers: [],
suppliersList: [],
topRecipients: [],
recipientsList: []
recipientsList: [],
annualSummary: [],
};
const allInvoices = await getInvoicesByUserId(userId);
// Le tableau de bord est global pour les administrateurs, comme les listes Factures.
const allInvoices = userId ? await getInvoicesByUserId(userId) : await getAllInvoices();
// Calculate basic stats
const completed = allInvoices.filter(i => i.status === "completed");
@@ -442,7 +489,8 @@ export async function getInvoiceStats(userId: number) {
topSuppliers,
suppliersList,
topRecipients,
recipientsList
recipientsList,
annualSummary: buildAnnualInvoiceSummary(allInvoices),
};
}
@@ -518,6 +566,57 @@ export async function getAllImportLogs(): Promise<ImportLog[]> {
return db.select().from(importLogs).orderBy(desc(importLogs.importedAt));
}
/**
* Retourne les informations nécessaires à l'audit d'un import sans exposer
* les paramètres sensibles de messagerie. L'administrateur peut ainsi relier
* chaque import à son compte, fichier source et déclencheur.
*/
export type ImportLogPageOptions = {
limit: number;
offset: number;
};
export async function getImportLogsWithDetailsByUser(userId: number, options: ImportLogPageOptions) {
const db = await getDb();
if (!db) return { items: [], total: 0 };
const items = await db.select({
...getTableColumns(importLogs),
userName: users.name,
userEmail: users.email,
sourceCreatedAt: sourceFiles.createdAt,
sourceStatus: sourceFiles.processingStatus,
})
.from(importLogs)
.leftJoin(users, eq(importLogs.userId, users.id))
.leftJoin(sourceFiles, eq(importLogs.sourceFileId, sourceFiles.id))
.where(eq(importLogs.userId, userId))
.orderBy(desc(importLogs.importedAt))
.limit(options.limit)
.offset(options.offset);
const [total] = await db.select({ total: count() }).from(importLogs).where(eq(importLogs.userId, userId));
return { items, total: Number(total?.total ?? 0) };
}
export async function getAllImportLogsWithDetails(options: ImportLogPageOptions) {
const db = await getDb();
if (!db) return { items: [], total: 0 };
const items = await db.select({
...getTableColumns(importLogs),
userName: users.name,
userEmail: users.email,
sourceCreatedAt: sourceFiles.createdAt,
sourceStatus: sourceFiles.processingStatus,
})
.from(importLogs)
.leftJoin(users, eq(importLogs.userId, users.id))
.leftJoin(sourceFiles, eq(importLogs.sourceFileId, sourceFiles.id))
.orderBy(desc(importLogs.importedAt))
.limit(options.limit)
.offset(options.offset);
const [total] = await db.select({ total: count() }).from(importLogs);
return { items, total: Number(total?.total ?? 0) };
}
export async function deleteAllImportLogs(userId: number | null): Promise<void> {
const db = await getDb();
if (!db) throw new Error("Database not available");
@@ -529,6 +628,25 @@ export async function deleteAllImportLogs(userId: number | null): Promise<void>
}
}
/** Compte les seuls journaux d'import qui seront concernés par une purge de rétention. */
export async function countImportLogsBefore(cutoff: Date): Promise<number> {
const db = await getDb();
if (!db) return 0;
const [result] = await db.select({ total: count() }).from(importLogs).where(lt(importLogs.importedAt, cutoff));
return Number(result?.total ?? 0);
}
/**
* Supprime uniquement les journaux d'import antérieurs à la borne donnée.
* Les factures, fichiers source et PDF ne sont jamais modifiés.
*/
export async function deleteImportLogsBefore(cutoff: Date): Promise<number> {
const db = await getDb();
if (!db) throw new Error("Database not available");
const result = await db.delete(importLogs).where(lt(importLogs.importedAt, cutoff));
return Number(result[0]?.affectedRows ?? 0);
}
// ============= LLM LOG OPERATIONS =============
export async function createLlmLog(data: InsertLlmLog): Promise<LlmLog> {
@@ -591,6 +709,24 @@ export async function upsertImportSettings(data: InsertImportSettings): Promise<
}
}
/** Liste administrative des comptes e-mail configurés, sans secret IMAP ni Azure AD. */
export async function getEmailImportAccounts() {
const db = await getDb();
if (!db) return [];
return db.select({
userId: importSettings.userId,
userName: users.name,
userEmail: users.email,
emailAddress: importSettings.emailImportAddress,
authMode: importSettings.emailImportAuthMode,
automaticEnabled: importSettings.emailImportEnabled,
isConfigured: sql<number>`CASE WHEN ${importSettings.emailImportAddress} IS NOT NULL AND ${importSettings.emailImportAddress} <> '' AND ${importSettings.emailImportHost} IS NOT NULL AND ${importSettings.emailImportHost} <> '' THEN 1 ELSE 0 END`.as("isConfigured"),
})
.from(importSettings)
.innerJoin(users, eq(importSettings.userId, users.id))
.orderBy(users.name, users.email);
}
// ============= DEPARTMENT LIST OPERATIONS =============
export async function getDepartmentsByUser(_userId?: number): Promise<Department[]> {
@@ -671,6 +807,47 @@ export async function deleteAutomationRule(id: number): Promise<void> {
await db.delete(automationRules).where(eq(automationRules.id, id));
}
// ============= EXPORT AUTOMATION RULES OPERATIONS =============
export async function getExportAutomationRulesByUser(userId: number): Promise<ExportAutomationRule[]> {
const db = await getDb();
if (!db) return [];
return db.select().from(exportAutomationRules)
.where(eq(exportAutomationRules.userId, userId))
.orderBy(exportAutomationRules.priority, exportAutomationRules.id);
}
export async function getExportAutomationRuleById(id: number): Promise<ExportAutomationRule | null> {
const db = await getDb();
if (!db) return null;
const [rule] = await db.select().from(exportAutomationRules).where(eq(exportAutomationRules.id, id));
return rule || null;
}
export async function createExportAutomationRule(data: InsertExportAutomationRule): Promise<ExportAutomationRule> {
const db = await getDb();
if (!db) throw new Error("Database not available");
const result = await db.insert(exportAutomationRules).values(data);
const rule = await getExportAutomationRuleById(result[0].insertId);
if (!rule) throw new Error("Export automation rule could not be created");
return rule;
}
export async function updateExportAutomationRule(id: number, data: Partial<InsertExportAutomationRule>): Promise<ExportAutomationRule> {
const db = await getDb();
if (!db) throw new Error("Database not available");
await db.update(exportAutomationRules).set(data).where(eq(exportAutomationRules.id, id));
const rule = await getExportAutomationRuleById(id);
if (!rule) throw new Error("Export automation rule not found");
return rule;
}
export async function deleteExportAutomationRule(id: number): Promise<void> {
const db = await getDb();
if (!db) throw new Error("Database not available");
await db.delete(exportAutomationRules).where(eq(exportAutomationRules.id, id));
}
// ============= INITIALIZE DEFAULT VALUES =============
export async function initializeDefaultLists(userId: number): Promise<void> {
@@ -1218,20 +1395,3 @@ export async function updateWebImportSourceStatus(
if (success) update.lastSuccessAt = new Date();
await db.update(webImportSources).set(update).where(eq(webImportSources.id, id));
}
/**
* Check if a source file with the same fileName already exists for this user
* Used to prevent duplicate file storage during email import
*/
export async function findSourceFileByFileName(userId: number, fileName: string): Promise<any | null> {
const db = await getDb();
if (!db) return null;
const result = await db.select()
.from(sourceFiles)
.where(and(
eq(sourceFiles.userId, userId),
eq(sourceFiles.fileName, fileName)
))
.limit(1);
return result[0] || null;
}

View File

@@ -0,0 +1,27 @@
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import { describe, expect, it } from "vitest";
const source = (path: string) => readFileSync(resolve(process.cwd(), path), "utf8");
describe("réimport d'une facture supprimée", () => {
it("ne traite plus une entrée deletedInvoices comme un doublon", () => {
for (const path of [
"server/routers.ts",
"server/emailImportService.ts",
"server/_core/index.ts",
]) {
expect(source(path)).not.toContain("isInvoiceBlacklisted");
}
});
it("conserve uniquement la détection des doublons réellement présents", () => {
for (const path of [
"server/routers.ts",
"server/emailImportService.ts",
"server/_core/index.ts",
]) {
expect(source(path)).toContain("findDuplicateInvoice");
}
});
});

View File

@@ -0,0 +1,47 @@
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
describe("routage et persistance de déploiement", () => {
it("utilise le domaine fourni par l’environnement avec la recette comme repli", () => {
const composeFile = readFileSync(join(process.cwd(), "docker-compose.yml"), "utf8");
expect(composeFile).toContain(
"Host(`${APP_DOMAIN:-demat-facturation.recette.santinova-soft.org}`)"
);
expect(composeFile).toContain("source: demat_facturation_storage");
expect(composeFile).toContain("target: /app/storage");
});
it("refuse de recréer le volume PDF et contrôle son montage avant le démarrage", () => {
const composeFile = readFileSync(join(process.cwd(), "docker-compose.yml"), "utf8");
const dockerfile = readFileSync(join(process.cwd(), "Dockerfile"), "utf8");
const guardScript = readFileSync(join(process.cwd(), "scripts", "ensure-storage-volume.sh"), "utf8");
expect(composeFile).toContain("STORAGE_BASE_PATH: /app/storage");
expect(composeFile).toContain("external: true");
expect(composeFile).toContain("nocopy: true");
expect(composeFile).toContain("grep -qs ' /app/storage ' /proc/mounts");
expect(dockerfile).toContain('CMD ["/app/scripts/ensure-storage-volume.sh"]');
expect(guardScript).toContain("Volume persistant non monté");
expect(guardScript).toContain("/proc/mounts");
});
it("réserve le contrôle bloquant du volume aux environnements Santinova", () => {
const composeFile = readFileSync(join(process.cwd(), "docker-compose.yml"), "utf8");
const guardScript = readFileSync(join(process.cwd(), "scripts", "ensure-storage-volume.sh"), "utf8");
expect(composeFile).toContain('REQUIRE_STORAGE_VOLUME: "true"');
expect(guardScript).toContain('REQUIRE_STORAGE_VOLUME:-false');
expect(guardScript).toContain('mkdir -p "$storage_path"');
});
it("injecte uniquement les secrets serveur de la passerelle Référentiel IA", () => {
const composeFile = readFileSync(join(process.cwd(), "docker-compose.yml"), "utf8");
expect(composeFile).toContain("REFERENTIEL_IA_URL: ${REFERENTIEL_IA_URL:-https://referentiel-ia-santinova.recette.santinova-soft.org}");
expect(composeFile).toContain("REFERENTIEL_IA_TOKEN: ${REFERENTIEL_IA_TOKEN:-}");
expect(composeFile).toContain("REFERENTIEL_IA_TOKEN_FILE: /run/secrets/referentiel_ia_token");
expect(composeFile).toContain("file: /opt/manus-secrets/demat-facturation-referentiel-ia-token");
});
});

View File

@@ -0,0 +1,14 @@
import { createHash } from "node:crypto";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import { describe, expect, it } from "vitest";
const migrationPath = resolve(process.cwd(), "drizzle/0036_broken_rattler.sql");
const recordedHash = "3c636f379884a82b48aea91af65532affc0bdd8f6c56fdac35e583c31ce3a8e3";
describe("historique Drizzle", () => {
it("conserve l'empreinte de la migration 0036 déjà enregistrée", () => {
const hash = createHash("sha256").update(readFileSync(migrationPath)).digest("hex");
expect(hash).toBe(recordedHash);
});
});

View File

@@ -0,0 +1,150 @@
import { describe, expect, it, vi } from "vitest";
import {
createEmailImportAccountController,
createImapFlowOptions,
type EmailImportConfig,
isEmailImportServiceRunning,
startConfiguredEmailImportService,
stopEmailImportService,
validateEmailImportConfiguration,
} from "./emailImportService";
const baseConfig: EmailImportConfig = {
userId: 2,
emailAddress: "compta@example.org",
password: "secret",
host: "outlook.office365.com",
port: 993,
};
describe("createImapFlowOptions", () => {
it("transmet le jeton brut à ImapFlow pour une authentification OAuth2", () => {
const options = createImapFlowOptions(
{ ...baseConfig, authMode: "oauth2" },
"access-token-value",
);
expect(options.secure).toBe(true);
expect(options.auth).toEqual({
user: "compta@example.org",
accessToken: "access-token-value",
});
expect(options.auth).not.toHaveProperty("pass");
expect(options.tls?.rejectUnauthorized).toBe(true);
expect(options.disableAutoIdle).toBe(true);
});
it("conserve le mot de passe uniquement pour le mode basique", () => {
const options = createImapFlowOptions({ ...baseConfig, authMode: "basic" });
expect(options.auth).toEqual({
user: "compta@example.org",
pass: "secret",
});
expect(options.auth).not.toHaveProperty("accessToken");
});
});
describe("validateEmailImportConfiguration", () => {
it("accepte une boîte OAuth2 configurée même lorsque la planification automatique est désactivée", () => {
const error = validateEmailImportConfiguration({
emailImportEnabled: 0,
emailImportAddress: "compta@example.org",
emailImportPassword: null,
emailImportHost: "outlook.office365.com",
emailImportPort: 993,
emailImportSinceDate: null,
emailImportAuthMode: "oauth2",
azureTenantId: "tenant",
azureClientId: "client",
azureClientSecret: "secret",
});
expect(error).toBeNull();
});
it("refuse une boîte dont la configuration IMAP est incomplète", () => {
const error = validateEmailImportConfiguration({
emailImportEnabled: 0,
emailImportAddress: "compta@example.org",
emailImportPassword: null,
emailImportHost: null,
emailImportPort: 993,
emailImportSinceDate: null,
emailImportAuthMode: "oauth2",
azureTenantId: "tenant",
azureClientId: "client",
azureClientSecret: "secret",
});
expect(error).toBe("Configuration IMAP incomplète");
});
});
describe("startConfiguredEmailImportService", () => {
it("ne crée aucune planification si le test IMAP préalable échoue", async () => {
const userId = 901;
stopEmailImportService(userId);
const result = await startConfiguredEmailImportService(
{ ...baseConfig, userId, authMode: "basic" },
5,
async () => ({
success: false,
message: "Authentification IMAP refusée.",
}),
);
expect(result).toEqual({
success: false,
message: "Authentification IMAP refusée.",
});
expect(isEmailImportServiceRunning(userId)).toBe(false);
});
it("démarre après un test IMAP concluant et exécute le contrôle initial", async () => {
const userId = 902;
const runEmailCheck = vi.fn(async () => undefined);
try {
const result = await startConfiguredEmailImportService(
{ ...baseConfig, userId, authMode: "basic" },
5,
async () => ({ success: true, message: "Connexion IMAP réussie." }),
runEmailCheck,
);
expect(result).toEqual({
success: true,
message: "Service de réception e-mail démarré",
});
expect(isEmailImportServiceRunning(userId)).toBe(true);
expect(runEmailCheck).toHaveBeenCalledWith(expect.objectContaining({ userId }));
} finally {
stopEmailImportService(userId);
}
});
});
describe("createEmailImportAccountController", () => {
it("cible exclusivement le compte sélectionné par un administrateur", async () => {
const start = vi.fn(async () => ({ success: true, message: "Service de réception e-mail démarré" }));
const stop = vi.fn();
const isRunning = vi.fn(() => true);
const controller = createEmailImportAccountController({ start, stop, isRunning });
await expect(controller.startAccount(42)).resolves.toEqual({
success: true,
message: "Service de réception e-mail démarré",
});
expect(start).toHaveBeenCalledWith(42);
expect(controller.stopAccount(42)).toEqual({
success: true,
message: "Service de réception e-mail arrêté",
});
expect(stop).toHaveBeenCalledWith(42);
expect(controller.accountStatus(42)).toEqual({ isRunning: true });
expect(isRunning).toHaveBeenCalledWith(42);
});
});

View File

@@ -1,23 +1,25 @@
import Imap from "imap";
import { ImapFlow, type ImapFlowOptions, type SearchObject } from "imapflow";
import { simpleParser, ParsedMail, Attachment } from "mailparser";
import {
getImportSettingsByUser,
createSourceFile,
updateSourceFile,
getUserSettings,
getLlmFieldsConfigByUser,
getSourceFileByContentHash,
findDuplicateInvoice,
isInvoiceBlacklisted,
createInvoice,
createImportLog,
findSourceFileByFileName,
} from "./db";
import { extractInvoicesWithMistral, generateMetadataJSON } from "./invoiceExtractor";
import { localStoragePut, generateStorageKey } from "./localStorage";
import { localStorageDelete, localStoragePut, generateStorageKey } from "./localStorage";
import { calculateFileSha256 } from "./fileFingerprint";
import { sendImportNotification } from "./notificationService";
import { getOffice365ImapToken, buildXOAuth2String } from "./office365OAuth";
import { getOffice365ImapToken } from "./office365OAuth";
import { applyAutomationRules } from "./automationEngine";
import { withCalculatedInvoiceQuality } from "@shared/invoiceQuality";
interface EmailImportConfig {
export interface EmailImportConfig {
userId: number;
emailAddress: string;
password: string;
@@ -31,10 +33,102 @@ interface EmailImportConfig {
azureClientSecret?: string;
}
export type EmailImportTrigger = "manual" | "automatic";
export type EmailImportServiceStartResult = {
success: boolean;
message: string;
};
type EmailConnectionTester = (
config: EmailImportConfig,
) => Promise<{ success: boolean; message: string }>;
type EmailCheckRunner = (config: EmailImportConfig) => Promise<void>;
type EmailImportSettingsSnapshot = {
emailImportEnabled: number;
emailImportAddress: string | null;
emailImportPassword: string | null;
emailImportHost: string | null;
emailImportPort: number | null;
emailImportSinceDate: number | null;
emailImportAuthMode: "basic" | "oauth2";
azureTenantId: string | null;
azureClientId: string | null;
azureClientSecret: string | null;
};
/** Vérifie une configuration IMAP sans tenir compte de l'activation de la planification. */
export function validateEmailImportConfiguration(
settings: EmailImportSettingsSnapshot | null | undefined,
): string | null {
if (!settings || !settings.emailImportAddress || !settings.emailImportHost) {
return "Configuration IMAP incomplète";
}
if (settings.emailImportAuthMode === "basic" && !settings.emailImportPassword) {
return "Mot de passe IMAP manquant";
}
if (
settings.emailImportAuthMode === "oauth2" &&
(!settings.azureTenantId || !settings.azureClientId || !settings.azureClientSecret)
) {
return "Configuration OAuth2 IMAP incomplète";
}
return null;
}
/**
* Construit les options ImapFlow sans effectuer d'appel réseau.
* ImapFlow reçoit le jeton brut et construit lui-même SASL XOAUTH2.
*/
export function createImapFlowOptions(
config: EmailImportConfig,
accessToken?: string,
): ImapFlowOptions {
const auth = config.authMode === "oauth2"
? { user: config.emailAddress, accessToken }
: { user: config.emailAddress, pass: config.password };
return {
host: config.host,
port: config.port,
secure: true,
auth,
tls: {
servername: config.host,
rejectUnauthorized: true,
},
logger: false,
disableAutoIdle: true,
connectionTimeout: 30_000,
greetingTimeout: 20_000,
socketTimeout: 120_000,
};
}
// Store active intervals for each user
const activeIntervals = new Map<number, NodeJS.Timeout>();
// Verrou anti-concurrence par userId
const runningChecks = new Set<number>();
// Une extraction IA peut dépasser la fréquence configurée : ce verrou évite
// qu'un second cycle IMAP traite les mêmes messages avant la fin du premier.
const activeChecks = new Map<number, Promise<void>>();
function runEmailCheckExclusive(
config: EmailImportConfig,
trigger: EmailImportTrigger = "automatic",
): Promise<void> {
const runningCheck = activeChecks.get(config.userId);
if (runningCheck) {
console.log(`[EmailImport] Vérification déjà en cours pour user ${config.userId}, cycle ignoré`);
return runningCheck;
}
const check = checkEmailsForPDFs(config, trigger).finally(() => {
if (activeChecks.get(config.userId) === check) activeChecks.delete(config.userId);
});
activeChecks.set(config.userId, check);
return check;
}
/**
* Process a single email attachment (PDF)
@@ -43,7 +137,8 @@ const runningChecks = new Set<number>();
async function processEmailAttachment(
userId: number,
attachment: Attachment,
emailSubject: string
emailSubject: string,
trigger: EmailImportTrigger,
): Promise<{ success: boolean; totalInvoices: number; imported: number; duplicates: number; errors: number; quotaError?: boolean }> {
const fileName = attachment.filename || `email-attachment-${Date.now()}.pdf`;
console.log(`[EmailImport] Processing attachment: ${fileName} from email: ${emailSubject}`);
@@ -52,14 +147,23 @@ async function processEmailAttachment(
// Convert attachment content to Buffer
const fileBuffer = attachment.content;
console.log(`[EmailImport] File size: ${fileBuffer.length} bytes`);
const contentHash = calculateFileSha256(fileBuffer);
const existingSource = await getSourceFileByContentHash(contentHash);
if (existingSource) {
console.log(
`[EmailImport] PDF déjà importé, extraction ignorée: ${fileName} -> source ${existingSource.id}`,
);
return {
success: true,
totalInvoices: Math.max(existingSource.totalInvoicesDetected, 1),
imported: 0,
duplicates: Math.max(existingSource.totalInvoicesDetected, 1),
errors: 0,
};
}
// Store source file
// ANTI-DUPLICATION : vérifier si ce fichier a déjà été importé pour cet utilisateur
const existingSourceFile = await findSourceFileByFileName(userId, fileName);
if (existingSourceFile) {
console.log(`[EmailImport] File ${fileName} already imported for user ${userId} (sourceFile #${existingSourceFile.id}), skipping`);
return { success: true, totalInvoices: 0, imported: 0, duplicates: 1, errors: 0 };
}
const sourceFileKey = generateStorageKey(userId, fileName);
console.log(`[EmailImport] Generated storage key: ${sourceFileKey}`);
@@ -74,13 +178,25 @@ async function processEmailAttachment(
}
// Create source file record
const sourceFile = await createSourceFile({
userId,
fileName,
fileKey: sourceFileKey,
fileUrl: sourceFileUrl,
processingStatus: "processing",
});
let sourceFile;
try {
sourceFile = await createSourceFile({
userId,
fileName,
fileKey: sourceFileKey,
fileUrl: sourceFileUrl,
contentHash,
processingStatus: "processing",
});
} catch (error: any) {
// La contrainte unique protège également contre deux imports concurrents.
if (error?.code === "ER_DUP_ENTRY" || error?.errno === 1062) {
await localStorageDelete(sourceFileKey).catch(() => undefined);
console.log(`[EmailImport] PDF réservé par un autre traitement: ${fileName}`);
return { success: true, totalInvoices: 1, imported: 0, duplicates: 1, errors: 0 };
}
throw error;
}
console.log(`[EmailImport] Source file record created with ID: ${sourceFile.id}`);
@@ -104,6 +220,7 @@ async function processEmailAttachment(
manusForgeApiUrl: settings.manusForgeApiUrl,
geminiApiKey: (settings as any).geminiApiKey || null,
} : undefined;
const qualityFieldsConfig = await getLlmFieldsConfigByUser(userId);
// Extract invoices
console.log(`[EmailImport] Starting invoice extraction (provider: ${aiSettings?.aiProvider || 'mistral'})...`);
@@ -133,6 +250,7 @@ async function processEmailAttachment(
for (let i = 0; i < result.invoices.length; i++) {
const invoiceData = result.invoices[i]!;
const invoiceWithQuality = withCalculatedInvoiceQuality(invoiceData, qualityFieldsConfig);
try {
// Update progress
@@ -140,19 +258,6 @@ async function processEmailAttachment(
processingProgress: `Extraction ${i + 1}/${result.invoiceCount} factures...`,
});
// Vérifier la blacklist (factures supprimées manuellement)
const blacklisted = await isInvoiceBlacklisted(invoiceData.invoiceNumber, userId);
if (blacklisted) {
duplicatesCount++;
duplicateDetails.push({
supplierName: invoiceData.supplierName,
invoiceNumber: invoiceData.invoiceNumber,
totalAmount: invoiceData.totalAmount,
reason: 'blacklisted',
});
continue;
}
// Check for duplicates (numéro de facture + montant)
const duplicate = await findDuplicateInvoice(
invoiceData.invoiceNumber,
@@ -195,7 +300,7 @@ async function processEmailAttachment(
totalAmount: invoiceData.totalAmount?.toString(),
recipientName: invoiceData.recipientName,
pageRange: invoiceData.pageRange,
qualityScore: invoiceData.qualityScore,
qualityScore: invoiceWithQuality.qualityScore,
extractedText: invoiceData.extractedText,
metadataFileKey: metadataKey,
metadataFileUrl: metadataUrl,
@@ -257,6 +362,7 @@ async function processEmailAttachment(
errorDetails: errorDetails.length > 0 ? JSON.stringify(errorDetails) : null,
warningMessage,
importSource: "email",
importTrigger: trigger,
});
console.log(`[EmailImport] Successfully processed attachment: ${fileName}`);
@@ -295,7 +401,7 @@ async function processEmailAttachment(
* - basic : login/password classique
* - oauth2 : obtient un token Azure AD et utilise XOAUTH2
*/
async function buildImapConfig(config: EmailImportConfig): Promise<Imap.Config> {
async function buildImapConfig(config: EmailImportConfig): Promise<ImapFlowOptions> {
if (config.authMode === "oauth2") {
if (!config.azureTenantId || !config.azureClientId || !config.azureClientSecret) {
throw new Error(
@@ -309,198 +415,119 @@ async function buildImapConfig(config: EmailImportConfig): Promise<Imap.Config>
config.azureClientId,
config.azureClientSecret
);
const xoauth2 = buildXOAuth2String(config.emailAddress, accessToken);
console.log(`[EmailImport] OAuth2 token obtained successfully`);
return {
user: config.emailAddress,
xoauth2,
host: config.host,
port: config.port,
tls: true,
tlsOptions: { rejectUnauthorized: false },
authTimeout: 30000,
} as any;
return createImapFlowOptions(config, accessToken);
}
// Basic auth (par défaut)
return {
user: config.emailAddress,
password: config.password,
host: config.host,
port: config.port,
tls: true,
tlsOptions: { rejectUnauthorized: false },
authTimeout: 30000,
};
return createImapFlowOptions(config);
}
/**
* Connect to IMAP and process unread emails with PDF attachments
*/
async function checkEmailsForPDFs(config: EmailImportConfig): Promise<void> {
// Anti-concurrence : ne pas lancer si un check est déjà en cours pour cet utilisateur
if (runningChecks.has(config.userId)) {
console.log(`[EmailImport] Check already running for user ${config.userId}, skipping`);
return;
}
runningChecks.add(config.userId);
// Build IMAP config (may involve async OAuth2 token fetch)
async function checkEmailsForPDFs(
config: EmailImportConfig,
trigger: EmailImportTrigger,
): Promise<void> {
const imapConfig = await buildImapConfig(config);
const client = new ImapFlow(imapConfig);
client.on("error", (error) => {
console.error(`[EmailImport] IMAP connection error for user ${config.userId}:`, error);
});
return new Promise((resolve, reject) => {
const imap = new Imap(imapConfig);
let lock: Awaited<ReturnType<ImapFlow["getMailboxLock"]>> | undefined;
try {
await client.connect();
console.log(
`[EmailImport] Connected to IMAP server for user ${config.userId} (mode: ${config.authMode || "basic"})`,
);
function openInbox(cb: (err: Error | null, box?: any) => void) {
imap.openBox("INBOX", false, cb);
lock = await client.getMailboxLock("INBOX", {
readOnly: false,
acquireTimeout: 30_000,
description: `invoice-import-user-${config.userId}`,
});
const searchCriteria: SearchObject = { seen: false };
if (config.sinceDate) {
searchCriteria.since = new Date(config.sinceDate * 1000);
console.log(
`[EmailImport] Filtering emails since ${searchCriteria.since.toISOString()} for user ${config.userId}`,
);
}
imap.once("ready", () => {
console.log(`[EmailImport] Connected to IMAP server for user ${config.userId} (mode: ${config.authMode || "basic"})`);
openInbox((err) => {
if (err) {
console.error("[EmailImport] Error opening inbox:", err);
imap.end();
reject(err);
return;
}
const unreadUids = await client.search(searchCriteria, { uid: true });
if (!unreadUids || unreadUids.length === 0) {
console.log(`[EmailImport] No unread emails found for user ${config.userId}`);
return;
}
// Build search criteria: unread emails, optionally filtered by date
const searchCriteria: any[] = ["UNSEEN"];
if (config.sinceDate) {
// IMAP SINCE expects a date string like "1-Jan-2026"
const since = new Date(config.sinceDate * 1000);
const months = ["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"];
const sinceStr = `${since.getDate()}-${months[since.getMonth()]}-${since.getFullYear()}`;
searchCriteria.push(["SINCE", sinceStr]);
console.log(`[EmailImport] Filtering emails since ${sinceStr} for user ${config.userId}`);
}
imap.search(searchCriteria, (err, results) => {
if (err) {
console.error("[EmailImport] Error searching emails:", err);
imap.end();
reject(err);
return;
}
console.log(`[EmailImport] Found ${unreadUids.length} unread emails for user ${config.userId}`);
if (!results || results.length === 0) {
console.log(`[EmailImport] No unread emails found for user ${config.userId}`);
imap.end();
resolve();
return;
}
// Le traitement reste séquentiel afin d'éviter plusieurs extractions IA
// concurrentes sur les mêmes pièces jointes.
for (const uid of unreadUids) {
const message = await client.fetchOne(uid, { source: true }, { uid: true });
if (!message || !message.source) {
console.warn(`[EmailImport] Message UID ${uid} without source, skipped`);
continue;
}
console.log(`[EmailImport] Found ${results.length} unread emails for user ${config.userId}`);
try {
const parsed: ParsedMail = await simpleParser(message.source);
const pdfAttachments = parsed.attachments.filter(
(attachment) =>
attachment.contentType === "application/pdf" ||
attachment.filename?.toLowerCase().endsWith(".pdf"),
);
const fetch = imap.fetch(results, {
bodies: "",
markSeen: true, // Mark as seen immediately to prevent re-processing
});
if (pdfAttachments.length === 0) continue;
const processedEmails: number[] = [];
console.log(`[EmailImport] Email UID ${uid} has ${pdfAttachments.length} PDF attachment(s)`);
let allAttachmentsSucceeded = true;
fetch.on("message", (msg, seqno) => {
msg.on("body", (stream) => {
simpleParser(stream as any, async (err, parsed: ParsedMail) => {
if (err) {
console.error("[EmailImport] Error parsing email:", err);
return;
}
for (const attachment of pdfAttachments) {
try {
const result = await processEmailAttachment(
config.userId,
attachment,
parsed.subject || "No subject",
trigger,
);
allAttachmentsSucceeded = allAttachmentsSucceeded && result.success;
// Check if email has PDF attachments
const pdfAttachments = parsed.attachments.filter(
(att) =>
att.contentType === "application/pdf" ||
att.filename?.toLowerCase().endsWith(".pdf")
);
if (pdfAttachments.length === 0) {
return;
}
console.log(
`[EmailImport] Email ${seqno} has ${pdfAttachments.length} PDF attachment(s)`
);
// Process each PDF attachment
for (const attachment of pdfAttachments) {
try {
const result = await processEmailAttachment(
config.userId,
attachment,
parsed.subject || "No subject"
);
// Mark this email as successfully processed
if (!processedEmails.includes(seqno)) {
processedEmails.push(seqno);
}
// Send notification after successful processing
if (result.success) {
await sendImportNotification(config.userId, {
source: "email",
fileName: attachment.filename || "email-attachment.pdf",
totalInvoices: result.totalInvoices,
imported: result.imported,
duplicates: result.duplicates,
errors: result.errors,
});
}
} catch (error) {
console.error(
`[EmailImport] Failed to process attachment from email ${seqno}:`,
error
);
}
}
if (result.success) {
await sendImportNotification(config.userId, {
source: "email",
fileName: attachment.filename || "email-attachment.pdf",
totalInvoices: result.totalInvoices,
imported: result.imported,
duplicates: result.duplicates,
errors: result.errors,
});
});
});
fetch.once("error", (err) => {
console.error("[EmailImport] Fetch error:", err);
imap.end();
reject(err);
});
fetch.once("end", () => {
console.log(`[EmailImport] Finished fetching emails for user ${config.userId}`);
// Mark successfully processed emails as seen
if (processedEmails.length > 0) {
imap.addFlags(processedEmails, ["\\Seen"], (err) => {
if (err) {
console.error("[EmailImport] Error marking emails as seen:", err);
} else {
console.log(`[EmailImport] Marked ${processedEmails.length} emails as seen`);
}
imap.end();
resolve();
});
} else {
imap.end();
resolve();
}
});
});
});
});
} catch (error) {
allAttachmentsSucceeded = false;
console.error(`[EmailImport] Failed to process attachment from UID ${uid}:`, error);
}
}
imap.once("error", (err) => {
runningChecks.delete(config.userId);
console.error("[EmailImport] IMAP connection error:", err);
reject(err);
});
if (allAttachmentsSucceeded) {
await client.messageFlagsAdd(uid, ["\\Seen"], { uid: true, silent: true });
console.log(`[EmailImport] Marked email UID ${uid} as seen`);
}
} catch (error) {
console.error(`[EmailImport] Error parsing email UID ${uid}:`, error);
}
}
imap.once("end", () => {
runningChecks.delete(config.userId);
console.log(`[EmailImport] IMAP connection ended for user ${config.userId}`);
});
imap.connect();
});
console.log(`[EmailImport] Finished processing emails for user ${config.userId}`);
} finally {
lock?.release();
if (client.usable) await client.logout().catch(() => client.close());
else client.close();
}
}
/**
@@ -508,76 +535,95 @@ async function checkEmailsForPDFs(config: EmailImportConfig): Promise<void> {
* Returns detailed error message if connection fails
*/
export async function testImapConnection(config: EmailImportConfig): Promise<{ success: boolean; message: string }> {
let client: ImapFlow | undefined;
try {
const imapConfig = await buildImapConfig(config);
return new Promise((resolve) => {
const imap = new Imap(imapConfig);
let resolved = false;
const done = (result: { success: boolean; message: string }) => {
if (!resolved) {
resolved = true;
try { imap.destroy(); } catch {}
resolve(result);
}
};
imap.once("ready", () => {
console.log(`[EmailImport] Test connection successful for ${config.emailAddress}`);
done({ success: true, message: `Connexion IMAP réussie pour ${config.emailAddress}` });
});
imap.once("error", (err: any) => {
console.error(`[EmailImport] Test connection failed:`, err);
let message = `Erreur de connexion IMAP : ${err.message || err}`;
// Messages d'erreur plus clairs
if (err.message?.includes("Invalid credentials") || err.message?.includes("AUTHENTICATE")) {
if (config.authMode === "oauth2") {
message = "Authentification OAuth2 refusée. Vérifiez que l'application Azure AD a bien la permission IMAP.AccessAsApp et que le consentement admin a été accordé.";
} else {
message = "Identifiants invalides. Pour Office 365, l'authentification basique est désactivée. Activez le mode OAuth2 et configurez les credentials Azure AD.";
}
} else if (err.message?.includes("ECONNREFUSED") || err.message?.includes("ENOTFOUND")) {
message = `Impossible de se connecter au serveur ${config.host}:${config.port}. Vérifiez l'adresse et le port IMAP.`;
} else if (err.message?.includes("certificate") || err.message?.includes("SSL")) {
message = `Erreur SSL/TLS lors de la connexion à ${config.host}. Vérifiez le port (993 pour SSL).`;
} else if (err.message?.includes("timeout") || err.message?.includes("Timeout")) {
message = `Timeout de connexion à ${config.host}:${config.port}. Vérifiez l'adresse du serveur IMAP.`;
}
done({ success: false, message });
});
// Timeout de sécurité
setTimeout(() => {
done({ success: false, message: `Timeout : impossible de se connecter à ${config.host}:${config.port} dans les 15 secondes.` });
}, 15000);
imap.connect();
});
client = new ImapFlow({ ...imapConfig, verifyOnly: true });
await client.connect();
console.log(`[EmailImport] Test connection successful for ${config.emailAddress}`);
return { success: true, message: `Connexion IMAP OAuth2 réussie pour ${config.emailAddress}` };
} catch (error: any) {
return { success: false, message: `Erreur : ${error.message || error}` };
console.error(`[EmailImport] Test connection failed:`, error);
const rawMessage = error?.response || error?.message || String(error);
let message = `Erreur de connexion IMAP : ${rawMessage}`;
if (/AUTHENTICATE|authentication|invalid credentials/i.test(rawMessage)) {
message = config.authMode === "oauth2"
? "Authentification OAuth2 refusée. Vérifiez IMAP.AccessAsApp, le consentement administrateur, le service principal Exchange et l’autorisation de la boîte."
: "Identifiants invalides. Pour Microsoft 365, utilisez OAuth2 au lieu de l’authentification basique.";
} else if (/ECONNREFUSED|ENOTFOUND/i.test(rawMessage)) {
message = `Impossible de joindre ${config.host}:${config.port}. Vérifiez l’adresse et le port IMAP.`;
} else if (/certificate|TLS|SSL/i.test(rawMessage)) {
message = `Erreur TLS lors de la connexion à ${config.host}. Vérifiez le certificat et le port 993.`;
} else if (/timeout/i.test(rawMessage)) {
message = `Timeout lors de la connexion à ${config.host}:${config.port}.`;
}
return { success: false, message };
} finally {
if (client?.usable) await client.logout().catch(() => client?.close());
else client?.close();
}
}
/**
* Start email import service for a user
* Vérifie la boîte avant de créer la planification. Une authentification refusée
* ne doit jamais produire une boucle de tentatives automatiques sans fin.
*/
export async function startEmailImportService(userId: number): Promise<boolean> {
export async function startConfiguredEmailImportService(
config: EmailImportConfig,
frequencyMinutes: number,
connectionTester: EmailConnectionTester = testImapConnection,
emailCheckRunner: EmailCheckRunner = runEmailCheckExclusive,
): Promise<EmailImportServiceStartResult> {
const connection = await connectionTester(config);
if (!connection.success) {
console.error(
`[EmailImport] Service non démarré pour user ${config.userId}: ${connection.message}`,
);
return connection;
}
// Stop existing service if running only after the new configuration has passed validation.
stopEmailImportService(config.userId);
const frequencyMs = Math.max(1, frequencyMinutes || 30) * 60 * 1000;
console.log(
`[EmailImport] Starting email import service for user ${config.userId} with frequency ${frequencyMinutes || 30} minutes (auth: ${config.authMode || "basic"})`,
);
// Run immediately on start. The preflight above confirms that the account accepts the credentials.
emailCheckRunner(config).catch((error) => {
console.error(`[EmailImport] Error checking emails for user ${config.userId}:`, error);
});
const interval = setInterval(() => {
emailCheckRunner(config).catch((error) => {
console.error(`[EmailImport] Error checking emails for user ${config.userId}:`, error);
});
}, frequencyMs);
activeIntervals.set(config.userId, interval);
console.log(`[EmailImport] Email import service started for user ${config.userId}`);
return { success: true, message: "Service de réception e-mail démarré" };
}
/**
* Start email import service for a user.
*/
export async function startEmailImportService(userId: number): Promise<EmailImportServiceStartResult> {
try {
// Get user's import settings
const settings = await getImportSettingsByUser(userId);
if (!settings || settings.emailImportEnabled !== 1) {
console.log(`[EmailImport] Email import not enabled for user ${userId}`);
return false;
return { success: false, message: "Activez et enregistrez la réception automatique avant de démarrer le service." };
}
if (!settings.emailImportAddress || !settings.emailImportHost) {
console.log(`[EmailImport] Email import configuration incomplete for user ${userId}`);
return false;
return { success: false, message: "Configuration IMAP incomplète : adresse e-mail ou serveur IMAP manquant." };
}
const authMode = (settings as any).emailImportAuthMode as "basic" | "oauth2" || "basic";
@@ -585,16 +631,13 @@ export async function startEmailImportService(userId: number): Promise<boolean>
// Vérification selon le mode d'auth
if (authMode === "basic" && !settings.emailImportPassword) {
console.log(`[EmailImport] Email import password missing for user ${userId}`);
return false;
return { success: false, message: "Mot de passe IMAP manquant." };
}
if (authMode === "oauth2" && (!settings.azureTenantId || !settings.azureClientId || !settings.azureClientSecret)) {
console.log(`[EmailImport] OAuth2 credentials incomplete for user ${userId}`);
return false;
return { success: false, message: "Configuration OAuth2 IMAP incomplète : renseignez les paramètres Microsoft Azure AD." };
}
// Stop existing service if running
stopEmailImportService(userId);
const config: EmailImportConfig = {
userId,
emailAddress: settings.emailImportAddress,
@@ -608,31 +651,13 @@ export async function startEmailImportService(userId: number): Promise<boolean>
azureClientSecret: settings.azureClientSecret || undefined,
};
const frequencyMs = (settings.emailImportFrequency || 30) * 60 * 1000; // Convert minutes to milliseconds
console.log(
`[EmailImport] Starting email import service for user ${userId} with frequency ${settings.emailImportFrequency} minutes (auth: ${authMode})`
return startConfiguredEmailImportService(
config,
settings.emailImportFrequency || 30,
);
// Run immediately on start
checkEmailsForPDFs(config).catch((error) => {
console.error(`[EmailImport] Error checking emails for user ${userId}:`, error);
});
// Set up interval for periodic checks
const interval = setInterval(() => {
checkEmailsForPDFs(config).catch((error) => {
console.error(`[EmailImport] Error checking emails for user ${userId}:`, error);
});
}, frequencyMs);
activeIntervals.set(userId, interval);
console.log(`[EmailImport] Email import service started for user ${userId}`);
return true;
} catch (error) {
console.error(`[EmailImport] Error starting email import service for user ${userId}:`, error);
return false;
return { success: false, message: "Erreur technique lors du démarrage de la réception e-mail." };
}
}
@@ -655,29 +680,55 @@ export function isEmailImportServiceRunning(userId: number): boolean {
return activeIntervals.has(userId);
}
type EmailImportAccountControllerDependencies = {
start: (userId: number) => Promise<EmailImportServiceStartResult>;
stop: (userId: number) => void;
isRunning: (userId: number) => boolean;
};
/**
* Manually trigger an immediate email check for a user
* Contrôles administratifs d'une boîte source. Les paramètres restent rattachés
* à leur compte propriétaire ; seul le service planifié est administré ici.
*/
export async function triggerEmailCheck(userId: number): Promise<{ success: boolean; message: string }> {
export function createEmailImportAccountController(
dependencies: EmailImportAccountControllerDependencies = {
start: startEmailImportService,
stop: stopEmailImportService,
isRunning: isEmailImportServiceRunning,
},
) {
return {
startAccount: (userId: number) => dependencies.start(userId),
stopAccount: (userId: number): EmailImportServiceStartResult => {
dependencies.stop(userId);
return { success: true, message: "Service de réception e-mail arrêté" };
},
accountStatus: (userId: number) => ({ isRunning: dependencies.isRunning(userId) }),
};
}
async function triggerConfiguredEmailCheck(
userId: number,
requireAutomaticEnabled: boolean,
): Promise<{ success: boolean; message: string }> {
try {
// Get user's import settings
const settings = await getImportSettingsByUser(userId);
if (!settings || settings.emailImportEnabled !== 1) {
if (!settings || (requireAutomaticEnabled && settings.emailImportEnabled !== 1)) {
return { success: false, message: "Import par email non activé" };
}
if (!settings.emailImportAddress || !settings.emailImportHost) {
return { success: false, message: "Configuration IMAP incomplète" };
}
const validationError = validateEmailImportConfiguration(settings);
if (validationError) return { success: false, message: validationError };
const authMode = (settings as any).emailImportAuthMode as "basic" | "oauth2" || "basic";
const config: EmailImportConfig = {
userId,
emailAddress: settings.emailImportAddress,
// validateEmailImportConfiguration garantit ces deux valeurs avant ce point.
emailAddress: settings.emailImportAddress!,
password: settings.emailImportPassword || "",
host: settings.emailImportHost,
host: settings.emailImportHost!,
port: settings.emailImportPort || 993,
sinceDate: settings.emailImportSinceDate ?? undefined,
authMode,
@@ -687,7 +738,8 @@ export async function triggerEmailCheck(userId: number): Promise<{ success: bool
};
console.log(`[EmailImport] Manual check triggered for user ${userId}`);
await checkEmailsForPDFs(config);
// Cette voie exécute une vérification unique : elle ne crée pas de setInterval.
await runEmailCheckExclusive(config, "manual");
return { success: true, message: "Vérification terminée avec succès" };
} catch (error: any) {
@@ -696,6 +748,16 @@ export async function triggerEmailCheck(userId: number): Promise<{ success: bool
}
}
/** Déclenchement manuel réservé aux appels administrateurs, même si le planificateur est désactivé. */
export async function triggerManualEmailCheck(userId: number): Promise<{ success: boolean; message: string }> {
return triggerConfiguredEmailCheck(userId, false);
}
/** Compatibilité avec le bouton existant : la vérification personnelle exige toujours l’activation automatique. */
export async function triggerEmailCheck(userId: number): Promise<{ success: boolean; message: string }> {
return triggerConfiguredEmailCheck(userId, true);
}
/**
* Stop all email import services
*/

View File

@@ -0,0 +1,39 @@
import { describe, expect, it } from "vitest";
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { findMatchingExportRule } from "../shared/exportAutomation";
const rules = [
{ isActive: 1, priority: 2, conditionField: "recipientName" as const, conditionValue: "Direction générale" },
{ isActive: 1, priority: 1, conditionField: "ventilationComptable" as const, conditionValue: "615200" },
{ isActive: 0, priority: 0, conditionField: "recipientName" as const, conditionValue: "Direction générale" },
];
describe("règles de destination d’export", () => {
it("sélectionne une règle de ventilation active", () => {
expect(findMatchingExportRule(rules, { recipientName: "Direction générale", ventilationComptable: "615200" })).toBe(rules[1]);
});
it("compare les destinataires sans tenir compte de la casse et des espaces", () => {
expect(findMatchingExportRule(rules, { recipientName: " direction GÉNÉRALE " })).toBe(rules[0]);
});
it("ignore les règles inactives", () => {
expect(findMatchingExportRule([rules[2]], { recipientName: "Direction générale" })).toBeUndefined();
});
it("ne retourne aucune règle si la facture ne correspond à aucun critère", () => {
expect(findMatchingExportRule(rules, { recipientName: "Service achats", ventilationComptable: "606000" })).toBeUndefined();
});
it("applique aussi le résolveur de destination aux exports PDF de factures sélectionnées", () => {
const routerSource = readFileSync(join(process.cwd(), "server", "routers.ts"), "utf8");
const exportPdfSection = routerSource.slice(
routerSource.indexOf("exportToPdf: protectedProcedure"),
routerSource.indexOf("exportInvoices: protectedProcedure"),
);
expect(exportPdfSection).toContain("resolveBapExportDestination(ctx.user.id, invoice, settings)");
expect(exportPdfSection).toContain("isMicrosoftDestination");
});
});

View File

@@ -0,0 +1,42 @@
import type { ImportSettings, Invoice } from "../drizzle/schema";
import { findMatchingExportRule } from "@shared/exportAutomation";
import { getExportAutomationRulesByUser } from "./db";
export type ResolvedExportDestination = {
exportMode: "browser" | "folder" | "both";
destinationPath: string | null;
destinationType: "local" | "teams" | "sharepoint";
source: "rule" | "default";
ruleName?: string;
};
/**
* Résout une destination pour une validation BAP. Une règle active ciblant le
* destinataire ou la ventilation est prioritaire sur les anciens paramètres
* globaux, qui restent le comportement de repli pour préserver l’existant.
*/
export async function resolveBapExportDestination(
userId: number,
invoice: Pick<Invoice, "recipientName" | "ventilationComptable">,
settings: ImportSettings | null,
): Promise<ResolvedExportDestination> {
const rules = await getExportAutomationRulesByUser(userId);
const matchingRule = findMatchingExportRule(rules, invoice);
if (matchingRule) {
return {
exportMode: matchingRule.openInBrowser === 1 ? "both" : "folder",
destinationPath: matchingRule.destinationPath,
destinationType: matchingRule.destinationType,
source: "rule",
ruleName: matchingRule.name,
};
}
return {
exportMode: settings?.bapExportMode || "browser",
destinationPath: settings?.exportFolder || null,
destinationType: settings?.exportFolderType || "local",
source: "default",
};
}

View File

@@ -0,0 +1,19 @@
import { describe, expect, it } from "vitest";
import { calculateFileSha256 } from "./fileFingerprint";
describe("calculateFileSha256", () => {
it("retourne la même empreinte pour un contenu identique", () => {
const content = Buffer.from("facture-pdf");
expect(calculateFileSha256(content)).toBe(calculateFileSha256(Buffer.from(content)));
});
it("distingue deux contenus différents", () => {
expect(calculateFileSha256(Buffer.from("facture-a"))).not.toBe(
calculateFileSha256(Buffer.from("facture-b")),
);
});
it("produit une empreinte SHA-256 hexadécimale", () => {
expect(calculateFileSha256(Buffer.from("facture"))).toMatch(/^[a-f0-9]{64}$/);
});
});

12
server/fileFingerprint.ts Normal file
View File

@@ -0,0 +1,12 @@
import { createHash } from "node:crypto";
/**
* Calcule une empreinte déterministe sur les octets du document original.
*
* L'empreinte est calculée avant tout stockage ou traitement IA : deux imports
* du même PDF sont donc reconnus même si le nom du fichier ou l'utilisateur
* diffèrent.
*/
export function calculateFileSha256(buffer: Buffer): string {
return createHash("sha256").update(buffer).digest("hex");
}

View File

@@ -4,15 +4,19 @@ import path from "path";
import {
getImportSettingsByUser,
createSourceFile,
getSourceFileByContentHash,
updateSourceFile,
getUserSettings,
getLlmFieldsConfigByUser,
findDuplicateInvoice,
createInvoice,
createImportLog,
} from "./db";
import { extractInvoicesWithMistral, generateMetadataJSON } from "./invoiceExtractor";
import { localStoragePut, generateStorageKey } from "./localStorage";
import { localStorageDelete, localStoragePut, generateStorageKey } from "./localStorage";
import { calculateFileSha256 } from "./fileFingerprint";
import { sendImportNotification } from "./notificationService";
import { withCalculatedInvoiceQuality } from "@shared/invoiceQuality";
interface FolderImportConfig {
userId: number;
@@ -41,6 +45,13 @@ async function processFolderFile(
// Read the file
const fileBuffer = await fs.readFile(filePath);
console.log(`[FolderImport] File size: ${fileBuffer.length} bytes`);
const contentHash = calculateFileSha256(fileBuffer);
const existingSource = await getSourceFileByContentHash(contentHash);
if (existingSource) {
console.log(`[FolderImport] PDF déjà importé, fichier ignoré: ${fileName}`);
return { success: true, imported: 0, duplicates: 1, errors: 0 };
}
// Store source file
const sourceFileKey = generateStorageKey(userId, fileName);
@@ -57,13 +68,23 @@ async function processFolderFile(
}
// Create source file record
const sourceFile = await createSourceFile({
userId,
fileName,
fileKey: sourceFileKey,
fileUrl: sourceFileUrl,
processingStatus: "processing",
});
let sourceFile;
try {
sourceFile = await createSourceFile({
userId,
fileName,
fileKey: sourceFileKey,
fileUrl: sourceFileUrl,
contentHash,
processingStatus: "processing",
});
} catch (error: any) {
if (error?.code === "ER_DUP_ENTRY" || error?.errno === 1062) {
await localStorageDelete(sourceFileKey).catch(() => undefined);
return { success: true, imported: 0, duplicates: 1, errors: 0 };
}
throw error;
}
console.log(`[FolderImport] Source file record created with ID: ${sourceFile.id}`);
@@ -80,6 +101,7 @@ async function processFolderFile(
} : undefined;
const model = settings?.llmModel || "mistral-large-latest";
const qualityFieldsConfig = await getLlmFieldsConfigByUser(userId);
// Extract invoices
console.log(`[FolderImport] Starting invoice extraction...`);
@@ -108,6 +130,7 @@ async function processFolderFile(
for (let i = 0; i < result.invoices.length; i++) {
const invoiceData = result.invoices[i]!;
const invoiceWithQuality = withCalculatedInvoiceQuality(invoiceData, qualityFieldsConfig);
try {
// Update progress
@@ -157,7 +180,7 @@ async function processFolderFile(
totalAmount: invoiceData.totalAmount?.toString(),
recipientName: invoiceData.recipientName,
pageRange: invoiceData.pageRange,
qualityScore: invoiceData.qualityScore,
qualityScore: invoiceWithQuality.qualityScore,
extractedText: invoiceData.extractedText,
metadataFileKey: metadataKey,
metadataFileUrl: metadataUrl,
@@ -192,6 +215,7 @@ async function processFolderFile(
duplicateDetails: duplicateDetails.length > 0 ? JSON.stringify(duplicateDetails) : null,
errorDetails: errorDetails.length > 0 ? JSON.stringify(errorDetails) : null,
importSource: "folder",
importTrigger: "automatic",
});
// Move file to processed folder

View File

@@ -0,0 +1,22 @@
import { describe, expect, it } from "vitest";
import {
DEFAULT_IMPORT_LOG_PAGE_SIZE,
MAX_IMPORT_LOG_PAGE_SIZE,
normalizeImportLogPagination,
} from "../shared/importLogPagination";
describe("pagination des journaux d'import", () => {
it("retourne une page bornée par défaut au lieu de l'historique complet", () => {
expect(normalizeImportLogPagination()).toEqual({
limit: DEFAULT_IMPORT_LOG_PAGE_SIZE,
offset: 0,
});
});
it("borne les entrées utilisateur pour préserver la réactivité", () => {
expect(normalizeImportLogPagination({ limit: MAX_IMPORT_LOG_PAGE_SIZE + 1, offset: -20 })).toEqual({
limit: MAX_IMPORT_LOG_PAGE_SIZE,
offset: 0,
});
});
});

View File

@@ -0,0 +1,24 @@
import { describe, expect, it } from "vitest";
import {
IMPORT_LOG_RETENTION_OPTIONS,
getImportLogRetentionCutoff,
isImportLogRetentionMonths,
} from "@shared/importLogRetention";
describe("rétention des journaux d'import", () => {
it("propose uniquement les périodes de purge autorisées", () => {
expect(IMPORT_LOG_RETENTION_OPTIONS).toEqual([3, 6, 12]);
expect(isImportLogRetentionMonths(3)).toBe(true);
expect(isImportLogRetentionMonths(6)).toBe(true);
expect(isImportLogRetentionMonths(12)).toBe(true);
expect(isImportLogRetentionMonths(9)).toBe(false);
});
it("calcule une date limite calendaire sans modifier la date de référence", () => {
const now = new Date("2026-10-09T12:00:00.000Z");
const cutoff = getImportLogRetentionCutoff(6, now);
expect(cutoff.toISOString()).toBe("2026-04-09T12:00:00.000Z");
expect(now.toISOString()).toBe("2026-10-09T12:00:00.000Z");
});
});

View File

@@ -0,0 +1,23 @@
import { describe, expect, it } from "vitest";
import { parseImportResultSummary } from "@shared/importResultSummary";
describe("parseImportResultSummary", () => {
it("identifie un traitement terminé sans facture créée", () => {
expect(parseImportResultSummary("Terminé: 0 importée(s), 1 doublon(s)"))
.toEqual({ importedCount: 0, duplicateCount: 1, errorCount: 0 });
});
it("identifie une facture réellement créée", () => {
expect(parseImportResultSummary("Terminé: 1 importée(s), 0 doublon(s)"))
.toEqual({ importedCount: 1, duplicateCount: 0, errorCount: 0 });
});
it("conserve les erreurs éventuelles", () => {
expect(parseImportResultSummary("Terminé: 0 importée(s), 0 doublon(s), 1 erreur(s)"))
.toEqual({ importedCount: 0, duplicateCount: 0, errorCount: 1 });
});
it("retourne null pour un statut intermédiaire", () => {
expect(parseImportResultSummary("Extraction 1/1 factures...")).toBeNull();
});
});

View File

@@ -0,0 +1,33 @@
import { describe, expect, it } from "vitest";
import { buildAnnualInvoiceSummary, buildSupplierBudgetSummary } from "@shared/invoiceAnalytics";
const invoices = [
{ invoiceDate: "2026-05-15", createdAt: "2026-05-16", status: "completed", isSubscription: 0, supplierName: "SFR", totalAmount: "100" },
{ invoiceDate: "2026-05-20", createdAt: "2026-05-21", status: "completed", isSubscription: 1, supplierName: "SFR", totalAmount: "20" },
{ invoiceDate: "2025-03-10", createdAt: "2025-03-11", status: "completed", isSubscription: 1, supplierName: "Microsoft", totalAmount: "50" },
{ invoiceDate: "2026-05-01", createdAt: "2026-05-01", status: "error", isSubscription: 0, supplierName: "Ignorée", totalAmount: "999" },
];
describe("agrégats de facturation", () => {
it("calcule les volumes et montants BAP et abonnements par année", () => {
expect(buildAnnualInvoiceSummary(invoices)).toEqual([
{ year: 2026, bapCount: 1, bapAmount: 100, subscriptionCount: 1, subscriptionAmount: 20, totalAmount: 120 },
{ year: 2025, bapCount: 0, bapAmount: 0, subscriptionCount: 1, subscriptionAmount: 50, totalAmount: 50 },
]);
});
it("filtre le budget par période et ventile chaque fournisseur par type", () => {
expect(buildSupplierBudgetSummary(invoices, "2026", "05")).toEqual([
{ supplierName: "SFR", subscriptionCount: 1, subscriptionAmount: 20, nonSubscriptionCount: 1, nonSubscriptionAmount: 100, totalAmount: 120 },
]);
});
it("classe le budget du fournisseur le plus facturé au moins facturé", () => {
const rows = buildSupplierBudgetSummary([
...invoices,
{ invoiceDate: "2026-05-22", createdAt: "2026-05-22", status: "completed", isSubscription: 0, supplierName: "Orange", totalAmount: "300" },
], "2026", "05");
expect(rows.map((row) => row.supplierName)).toEqual(["Orange", "SFR"]);
});
});

View File

@@ -0,0 +1,30 @@
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import { describe, expect, it } from "vitest";
import { INVOICE_DELETE_ERROR, getSafeInvoiceDeletionError } from "./invoiceDeletionError";
const migrationPath = resolve(process.cwd(), "drizzle/0042_deleted_invoices_blacklist.sql");
const dbPath = resolve(process.cwd(), "server/db.ts");
describe("suppression de facture", () => {
it("ne renvoie jamais les détails SQL au navigateur", () => {
const error = new Error("Failed query: insert into `deletedInvoices` values (?, ?, ?)");
expect(getSafeInvoiceDeletionError(error)).toBe(INVOICE_DELETE_ERROR);
});
it("crée la blacklist de manière additive et idempotente", () => {
const migration = readFileSync(migrationPath, "utf8");
expect(migration).toContain("CREATE TABLE IF NOT EXISTS `deletedInvoices`");
expect(migration).toContain("CONSTRAINT `deletedInvoices_id` PRIMARY KEY(`id`)");
});
it("met à jour une entrée existante sans exiger de contrainte unique historique", () => {
const source = readFileSync(dbPath, "utf8");
expect(source).toContain("const blacklistEntry = await db.select");
expect(source).toContain("await db.update(deletedInvoices)");
expect(source).not.toContain("}).onDuplicateKeyUpdate({ set: { deletedAt: new Date() } });");
});
});

View File

@@ -0,0 +1,10 @@
/** Message utilisateur sans détails de requête ni données de facture. */
export const INVOICE_DELETE_ERROR = "La facture ne peut pas être supprimée pour le moment. Réessayez dans quelques instants.";
/**
* Les erreurs de persistance peuvent contenir une requête SQL et des paramètres.
* Ils restent réservés aux journaux serveur et ne doivent jamais atteindre le navigateur.
*/
export function getSafeInvoiceDeletionError(_error: unknown): string {
return INVOICE_DELETE_ERROR;
}

View File

@@ -0,0 +1,29 @@
import { describe, expect, it } from "vitest";
import { cleanJsonResponse } from "./invoiceExtractor";
describe("cleanJsonResponse", () => {
it("supprime les caractères de contrôle bruts dans une chaîne JSON LLM", () => {
const rawResponse = `{
"pageCount": 1,
"invoiceCount": 1,
"invoices": [{
"supplierName": "Manus AI",
"invoiceNumber": "J6LNGDAL-0021",
"extractedText": "Ligne valide\u0000suite du texte"
}]
}`;
const parsed = JSON.parse(cleanJsonResponse(rawResponse));
expect(parsed.invoices[0].invoiceNumber).toBe("J6LNGDAL-0021");
expect(parsed.invoices[0].extractedText).toBe("Ligne validesuite du texte");
});
it("préserve les retours à la ligne échappés dans les chaînes JSON", () => {
const rawResponse = '{"invoices":[{"extractedText":"Ligne 1\nLigne 2"}] }';
const parsed = JSON.parse(cleanJsonResponse(rawResponse));
expect(parsed.invoices[0].extractedText).toBe("Ligne 1\nLigne 2");
});
});

View File

@@ -1,4 +1,4 @@
import { invokeLLMWithUserSettings } from "./_core/llm";
import { invokeLLMWithUserSettings, type UserLlmSettings } from "./_core/llm";
import { PDFDocument } from "pdf-lib";
import { createLlmLog } from "./db";
import PDFParser from "pdf2json";
@@ -75,9 +75,54 @@ async function extractTextFromPdf(pdfBuffer: Buffer): Promise<string> {
}
/**
* Clean JSON response from LLM by removing markdown code blocks
* Supprime ou échappe les caractères de contrôle bruts uniquement à
* l'intérieur des chaînes JSON. Certains moteurs retournent ponctuellement
* un caractère NUL provenant du texte OCR : JSON.parse le refuse alors que
* le reste de la réponse est exploitable.
*/
function cleanJsonResponse(content: string): string {
function normalizeControlCharactersInJsonStrings(content: string): string {
let normalized = "";
let inString = false;
let escaped = false;
for (const character of content) {
if (escaped) {
normalized += character;
escaped = false;
continue;
}
if (character === "\\") {
normalized += character;
escaped = true;
continue;
}
if (character === '"') {
inString = !inString;
normalized += character;
continue;
}
if (inString && character.charCodeAt(0) < 0x20) {
if (character === "\n") normalized += "\\n";
else if (character === "\r") normalized += "\\r";
else if (character === "\t") normalized += "\\t";
// Les autres contrôles (notamment NUL) n'ont pas de valeur métier.
continue;
}
normalized += character;
}
return normalized;
}
/**
* Clean JSON response from LLM by removing markdown code blocks.
* Exportée pour couvrir les réponses LLM mal formées en régression.
*/
export function cleanJsonResponse(content: string): string {
let cleaned = content.trim();
// Remove markdown code blocks
@@ -123,7 +168,76 @@ function cleanJsonResponse(content: string): string {
cleaned = cleaned.substring(startIdx, endIdx + 1);
}
return cleaned;
return normalizeControlCharactersInJsonStrings(cleaned);
}
export type InvoiceExtractionPromptInput = {
pageCount: number;
qualityScoreInstruction: string;
subscriptionInstruction: string;
keywordsHint: string;
};
/**
* Construit une demande JSON compacte. Le texte PDF reste en stockage local :
* les moteurs rapides à sortie limitée ne doivent pas le recopier dans leur JSON.
*/
export function buildInvoiceExtractionPrompt({
pageCount,
qualityScoreInstruction,
subscriptionInstruction,
keywordsHint,
}: InvoiceExtractionPromptInput): string {
return `Tu es un expert en extraction de données de factures. Analyse ce document PDF et extrais toutes les factures qu'il contient.
Pour chaque facture trouvée, extrais les informations suivantes:
- supplierName: Nom du fournisseur/vendeur (émetteur de la facture)
- invoiceNumber: Numéro de la facture
- invoiceDate: Date de la facture (format ISO 8601: YYYY-MM-DD)
- deliveryNoteNumber: Numéro du bon de livraison (si présent)
- orderNumber: Numéro de commande client (si présent)
- totalAmount: Montant total TTC (nombre décimal)
- recipientName: Nom du destinataire/client (société ou personne à qui la facture est adressée)
- pageRange: Plage de pages de cette facture (ex: "1-2" ou "5")
${qualityScoreInstruction}
${subscriptionInstruction}${keywordsHint}
Réponds UNIQUEMENT avec un objet JSON valide au format suivant:
{
"pageCount": ${pageCount},
"invoiceCount": <nombre de factures détectées>,
"invoices": [
{
"supplierName": "...",
"invoiceNumber": "...",
"invoiceDate": "YYYY-MM-DD",
"deliveryNoteNumber": "...",
"orderNumber": "...",
"totalAmount": 123.45,
"recipientName": "...",
"pageRange": "1-2",
"qualityScore": 85,
"isSubscription": false
}
]
}
Si une information n'est pas trouvée, utilise null. Ne retourne jamais le texte complet du PDF, ni aucun texte en dehors du JSON.`;
}
/** Attache le texte PDF déjà extrait localement, sans demander au modèle de le recopier. */
export function attachLocallyExtractedText(
result: Omit<MultiInvoiceResult, "invoices"> & { invoices: Array<Record<string, unknown>> },
pdfText: string,
): MultiInvoiceResult {
return {
...result,
invoices: result.invoices.map((invoice) => ({
...invoice,
invoiceDate: invoice.invoiceDate ? new Date(String(invoice.invoiceDate)) : null,
extractedText: pdfText,
})) as ExtractedInvoiceData[],
};
}
/**
@@ -144,7 +258,7 @@ export async function extractInvoicesWithMistral(
subscription?: string | null;
recipient?: string | null;
},
aiSettings?: { aiProvider?: string | null; mistralApiKey?: string | null; manusForgeApiKey?: string | null; manusForgeApiUrl?: string | null }
aiSettings?: UserLlmSettings
): Promise<MultiInvoiceResult> {
// Load user's field configuration
const { getLlmFieldsConfigByUser } = await import("./db");
@@ -200,48 +314,16 @@ export async function extractInvoicesWithMistral(
subscriptionInstruction = `- isSubscription: true UNIQUEMENT si la facture contient l'un de ces mots-clés exacts: ${customKeywords.subscription}, false sinon`;
}
// Prepare prompt for LLM
const prompt = `Tu es un expert en extraction de données de factures. Analyse ce document PDF et extrais toutes les factures qu'il contient.
Pour chaque facture trouvée, extrais les informations suivantes:
- supplierName: Nom du fournisseur/vendeur (émetteur de la facture)
- invoiceNumber: Numéro de la facture
- invoiceDate: Date de la facture (format ISO 8601: YYYY-MM-DD)
- deliveryNoteNumber: Numéro du bon de livraison (si présent)
- orderNumber: Numéro de commande client (si présent)
- totalAmount: Montant total TTC (nombre décimal)
- recipientName: Nom du destinataire/client (société ou personne à qui la facture est adressée)
- pageRange: Plage de pages de cette facture (ex: "1-2" ou "5")
${qualityScoreInstruction}
- extractedText: Texte complet extrait de la facture (tout le texte visible sur les pages de cette facture)
${subscriptionInstruction}${keywordsHint}
Réponds UNIQUEMENT avec un objet JSON valide au format suivant:
{
"pageCount": ${pageCount},
"invoiceCount": <nombre de factures détectées>,
"invoices": [
{
"supplierName": "...",
"invoiceNumber": "...",
"invoiceDate": "YYYY-MM-DD",
"deliveryNoteNumber": "...",
"orderNumber": "...",
"totalAmount": 123.45,
"recipientName": "...",
"pageRange": "1-2",
"qualityScore": 85,
"extractedText": "Texte complet de la facture...",
"isSubscription": false
}
]
}
Si une information n'est pas trouvée, utilise null. Ne retourne AUCUN texte en dehors du JSON.`;
const prompt = buildInvoiceExtractionPrompt({
pageCount,
qualityScoreInstruction,
subscriptionInstruction,
keywordsHint,
});
// Call Mistral LLM with extracted text
const fullPrompt = `${prompt}\n\nTexte extrait du PDF:\n${pdfText}`;
const response = await invokeLLMWithUserSettings(
const invocation = await invokeLLMWithUserSettings(
{
messages: [
{
@@ -252,13 +334,18 @@ Si une information n'est pas trouvée, utilise null. Ne retourne AUCUN texte en
},
aiSettings
);
const { response, trace } = invocation;
const rawResponse = typeof response.choices[0]?.message?.content === "string"
? response.choices[0].message.content
: JSON.stringify(response.choices[0]?.message?.content || "");
const processingTimeMs = Date.now() - startTime;
console.log("[Mistral] Raw response received:", rawResponse.substring(0, 200));
console.log("[Mistral] Raw response received", {
length: rawResponse.length,
startsWithJson: rawResponse.trimStart().startsWith("{"),
endsWithJson: rawResponse.trimEnd().endsWith("}"),
});
// Clean and parse response
const cleanedResponse = cleanJsonResponse(rawResponse);
@@ -268,7 +355,11 @@ Si une information n'est pas trouvée, utilise null. Ne retourne AUCUN texte en
result = JSON.parse(cleanedResponse);
} catch (parseError) {
console.error("[Mistral] Failed to parse JSON:", parseError);
console.error("[Mistral] Cleaned response:", cleanedResponse);
console.error("[Mistral] Invalid response shape", {
length: cleanedResponse.length,
startsWithJson: cleanedResponse.startsWith("{"),
endsWithJson: cleanedResponse.endsWith("}"),
});
// Log error to database
await createLlmLog({
@@ -276,6 +367,9 @@ Si une information n'est pas trouvée, utilise null. Ne retourne AUCUN texte en
sourceFileId,
operation: "extraction",
model,
resolvedProvider: trace.provider,
resolvedModel: trace.model,
transport: trace.transport,
promptSent: prompt,
rawResponse: rawResponse.substring(0, 10000),
cleanedResponse: cleanedResponse.substring(0, 10000),
@@ -293,6 +387,9 @@ Si une information n'est pas trouvée, utilise null. Ne retourne AUCUN texte en
sourceFileId,
operation: "extraction",
model,
resolvedProvider: trace.provider,
resolvedModel: trace.model,
transport: trace.transport,
promptSent: prompt.substring(0, 10000),
rawResponse: rawResponse.substring(0, 10000),
cleanedResponse: cleanedResponse.substring(0, 10000),
@@ -300,11 +397,7 @@ Si une information n'est pas trouvée, utilise null. Ne retourne AUCUN texte en
processingTimeMs,
});
// Convert date strings to Date objects
result.invoices = result.invoices.map((inv) => ({
...inv,
invoiceDate: inv.invoiceDate ? new Date(inv.invoiceDate) : null,
}));
result = attachLocallyExtractedText(result as any, pdfText);
console.log(`[Mistral] Successfully extracted ${result.invoiceCount} invoice(s)`);

View File

@@ -0,0 +1,64 @@
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import { describe, expect, it } from "vitest";
import { calculateInvoiceQualityScore, withCalculatedInvoiceQuality } from "@shared/invoiceQuality";
const routerSource = readFileSync(resolve(process.cwd(), "server/routers.ts"), "utf8");
const emailImportSource = readFileSync(resolve(process.cwd(), "server/emailImportService.ts"), "utf8");
const folderImportSource = readFileSync(resolve(process.cwd(), "server/folderImportService.ts"), "utf8");
const webImportSource = readFileSync(resolve(process.cwd(), "server/_core/index.ts"), "utf8");
describe("score de qualité à l'import", () => {
it("calcule 100 % pour la facture lorsque tous les champs obligatoires configurés sont présents", () => {
const qualityScore = calculateInvoiceQualityScore({
supplierName: "Manus Butterfly",
invoiceNumber: "J6LNGDAL0021",
invoiceDate: new Date("2026-08-28"),
totalAmount: "120.00",
recipientName: "PAREIGE OLIVIER",
}, [
{ fieldName: "supplierName", isRequired: 1 },
{ fieldName: "invoiceNumber", isRequired: 1 },
{ fieldName: "invoiceDate", isRequired: 1 },
{ fieldName: "totalAmount", isRequired: 1 },
{ fieldName: "recipientName", isRequired: 1 },
{ fieldName: "orderNumber", isRequired: 0 },
]);
expect(qualityScore).toBe(100);
});
it("utilise le calcul serveur déterministe au lieu du score proposé par le modèle", () => {
expect(routerSource).toContain("withCalculatedInvoiceQuality(invoiceData, qualityFieldsConfig)");
expect(routerSource).toContain("qualityScore: invoiceWithQuality.qualityScore");
});
it("exécute le même recalcul pour chaque flux d’import", () => {
const requiredFields = [
{ fieldName: "supplierName", isRequired: 1 },
{ fieldName: "invoiceNumber", isRequired: 1 },
{ fieldName: "invoiceDate", isRequired: 1 },
{ fieldName: "totalAmount", isRequired: 1 },
{ fieldName: "recipientName", isRequired: 1 },
];
for (const rawModelScore of [0, 42, 90, 99]) {
const persistedInvoice = withCalculatedInvoiceQuality({
supplierName: "Manus Butterfly",
invoiceNumber: "J6LNGDAL0021",
invoiceDate: new Date("2026-08-28"),
totalAmount: "120.00",
recipientName: "PAREIGE OLIVIER",
qualityScore: rawModelScore,
}, requiredFields);
expect(persistedInvoice.qualityScore).toBe(100);
}
});
it("branche chaque flux sur le calcul partagé", () => {
for (const source of [routerSource, emailImportSource, folderImportSource, webImportSource]) {
expect(source).toContain("withCalculatedInvoiceQuality(");
expect(source).toContain("qualityFieldsConfig");
}
});
});

View File

@@ -0,0 +1,17 @@
import { describe, expect, it } from "vitest";
import { getInvoicePeriodDate, matchesInvoicePeriod } from "@shared/invoicePeriod";
describe("filtres de période des factures", () => {
it("privilégie la date de facture et accepte le mois demandé", () => {
const invoice = { invoiceDate: "2026-05-11T00:00:00.000Z", createdAt: "2026-06-02T00:00:00.000Z" };
expect(getInvoicePeriodDate(invoice)?.getFullYear()).toBe(2026);
expect(matchesInvoicePeriod(invoice, "2026", "05")).toBe(true);
expect(matchesInvoicePeriod(invoice, "2026", "06")).toBe(false);
});
it("utilise la date de réception lorsque la date de facture est absente", () => {
const invoice = { invoiceDate: null, createdAt: "2025-01-31T00:00:00.000Z" };
expect(matchesInvoicePeriod(invoice, "2025", "01")).toBe(true);
expect(matchesInvoicePeriod(invoice, "2026", "all")).toBe(false);
});
});

View File

@@ -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);
});
});

66
server/llm-trace.test.ts Normal file
View File

@@ -0,0 +1,66 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { invokeLLMWithUserSettings, resolveLlmTrace } from "./_core/llm";
describe("resolveLlmTrace", () => {
it("trace Manus Forge avec le modèle réellement résolu", () => {
expect(resolveLlmTrace({ aiProvider: "manus" })).toEqual({
provider: "manus",
model: "gemini-2.5-flash",
transport: "manus-forge",
});
});
it("trace Mistral et Gemini sans déduire le modèle depuis un champ historique", () => {
expect(resolveLlmTrace({ aiProvider: "mistral" })).toEqual({
provider: "mistral",
model: "mistral-large-latest",
transport: "mistral-api",
});
expect(resolveLlmTrace({ aiProvider: "gemini" })).toEqual({
provider: "gemini",
model: "gemini-2.0-flash",
transport: "gemini-api",
});
});
it("reste sur Mistral lorsque le fournisseur est absent", () => {
expect(resolveLlmTrace()).toEqual({
provider: "mistral",
model: "mistral-large-latest",
transport: "mistral-api",
});
});
});
describe("invokeLLMWithUserSettings", () => {
afterEach(() => vi.unstubAllGlobals());
it("retourne la trace Manus correspondant au payload réellement envoyé", async () => {
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({ id: "test", created: 0, model: "gemini-2.5-flash", choices: [] }),
});
vi.stubGlobal("fetch", fetchMock);
const invocation = await invokeLLMWithUserSettings(
{ messages: [{ role: "user", content: "test non sensible" }] },
{
aiProvider: "manus",
manusForgeApiKey: "test-only-not-a-secret",
manusForgeApiUrl: "https://forge.example.test",
},
);
expect(invocation.trace).toEqual({
provider: "manus",
model: "gemini-2.5-flash",
transport: "manus-forge",
});
expect(fetchMock).toHaveBeenCalledWith(
"https://forge.example.test/v1/chat/completions",
expect.objectContaining({ method: "POST" }),
);
const request = fetchMock.mock.calls[0][1] as RequestInit;
expect(JSON.parse(String(request.body))).toMatchObject({ model: "gemini-2.5-flash" });
});
});

View File

@@ -0,0 +1,28 @@
import { describe, expect, it } from "vitest";
import {
getManualWebImportAvailability,
runManualWebImport,
summarizeManualWebImport,
} from "./manualWebImport";
describe("manual web import", () => {
it("autorise uniquement le connecteur SFR actuellement implémenté", () => {
expect(getManualWebImportAvailability("sfr")).toEqual({ available: true });
expect(getManualWebImportAvailability("starlink")).toMatchObject({ available: false });
});
it("agrège les résultats d’envoi sans exposer de données de connexion", () => {
expect(
summarizeManualWebImport([
{ imported: 2, duplicates: 1, total: 3 },
{ imported: 0, duplicates: 1, total: 1 },
]),
).toEqual({ imported: 2, duplicates: 2, total: 4 });
});
it("refuse un type non pris en charge avant de démarrer le navigateur", async () => {
await expect(
runManualWebImport({ connectorType: "starlink" } as any),
).rejects.toThrow("n’est pas encore disponible");
});
});

129
server/manualWebImport.ts Normal file
View File

@@ -0,0 +1,129 @@
import { chromium } from "playwright-core";
import type { WebImportSource } from "../drizzle/schema";
export type ManualWebImportResult = {
imported: number;
duplicates: number;
total: number;
};
type InvoicePushResult = ManualWebImportResult;
type InvoicePusher = (fileName: string, pdfBuffer: Buffer) => Promise<InvoicePushResult>;
const SFR_INVOICE_LINK_SELECTOR =
'a[href*=".pdf"], a[href*="facture"], a[href*="invoice"], a[download]';
/** Types de connecteurs dont l’automate est réellement livré dans l’image applicative. */
export function getManualWebImportAvailability(connectorType: string):
| { available: true }
| { available: false; reason: string } {
if (connectorType === "sfr") return { available: true };
return {
available: false,
reason: `L’import manuel n’est pas encore disponible pour le connecteur « ${connectorType} ».`,
};
}
export function summarizeManualWebImport(results: InvoicePushResult[]): ManualWebImportResult {
return results.reduce(
(summary, result) => ({
imported: summary.imported + result.imported,
duplicates: summary.duplicates + result.duplicates,
total: summary.total + result.total,
}),
{ imported: 0, duplicates: 0, total: 0 },
);
}
function getApplicationBaseUrl(): string {
return process.env.APP_BASE_URL || `http://127.0.0.1:${process.env.PORT || 3000}`;
}
async function pushInvoiceToApplication(source: WebImportSource, fileName: string, pdfBuffer: Buffer) {
const response = await fetch(`${getApplicationBaseUrl()}/api/web-import/push-invoice`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
apiToken: source.apiToken,
fileName,
fileBase64: pdfBuffer.toString("base64"),
}),
});
if (!response.ok) {
throw new Error(`Le dépôt sécurisé du PDF a échoué (HTTP ${response.status}).`);
}
const result = (await response.json()) as Partial<InvoicePushResult>;
return {
imported: Number(result.imported || 0),
duplicates: Number(result.duplicates || 0),
total: Number(result.total || 0),
};
}
/**
* Télécharge les nouvelles factures SFR puis les soumet au même endpoint
* de contrôle, dédoublonnage, stockage et analyse IA que l’import automatique.
* Les identifiants restent confinés au processus serveur et ne sont jamais loggés.
*/
export async function runManualWebImport(
source: WebImportSource,
pushInvoice: InvoicePusher = (fileName, pdfBuffer) => pushInvoiceToApplication(source, fileName, pdfBuffer),
): Promise<ManualWebImportResult> {
const availability = getManualWebImportAvailability(source.connectorType);
if (!availability.available) throw new Error(availability.reason);
const browser = await chromium.launch({
headless: true,
executablePath: process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE || "/usr/bin/chromium",
args: ["--no-sandbox", "--disable-dev-shm-usage"],
});
try {
const context = await browser.newContext({ acceptDownloads: true });
const page = await context.newPage();
await page.goto(source.portalUrl, { waitUntil: "networkidle", timeout: 30_000 });
await page
.click('[id*="accept"], [class*="accept-cookie"], #didomi-notice-agree-button', { timeout: 3_000 })
.catch(() => undefined);
await page.fill('input[type="email"], input[name="login"], input[id*="login"], input[id*="email"]', source.loginEmail);
await page.fill('input[type="password"], input[name="password"], input[id*="password"]', source.loginPassword);
await page.click('button[type="submit"], input[type="submit"], button:has-text("Connexion"), button:has-text("Se connecter")');
await page.waitForLoadState("networkidle", { timeout: 15_000 }).catch(() => undefined);
const invoicePage = new URL("factures", source.portalUrl).toString();
await page.goto(invoicePage, { waitUntil: "networkidle", timeout: 20_000 }).catch(() => undefined);
const invoiceLinks = await page.$$eval(SFR_INVOICE_LINK_SELECTOR, (links) =>
links.map((link) => ({
href: (link as HTMLAnchorElement).href,
label: link.textContent?.trim() || "facture.pdf",
})),
);
const uniqueLinks = Array.from(
new Map(invoiceLinks.filter((link) => link.href).map((link) => [link.href, link])).values(),
);
const results: InvoicePushResult[] = [];
for (const link of uniqueLinks) {
// BrowserContext.request partage la session authentifiée du navigateur. Cela évite
// de dépendre du comportement du visualiseur PDF ou de l’événement download.
const response = await context.request.get(link.href, { timeout: 20_000 });
if (!response.ok()) {
throw new Error(`Téléchargement de facture indisponible (HTTP ${response.status()}).`);
}
const fileName = link.label || `sfr-facture-${Date.now()}.pdf`;
const pdfBuffer = Buffer.from(await response.body());
results.push(await pushInvoice(fileName, pdfBuffer));
}
return summarizeManualWebImport(results);
} finally {
await browser.close();
}
}

View File

@@ -0,0 +1,127 @@
import { describe, expect, it } from "vitest";
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import {
invokeReferentialIa,
testReferentialIaConnection,
type ReferentielIaConnection,
} from "./referentialIa";
import { readSecretFile, resolveServerSecret } from "./_core/env";
const connection: ReferentielIaConnection = {
baseUrl: "https://referentiel-ia-santinova.recette.santinova-soft.org",
token: "test-token",
};
function jsonResponse(body: unknown, status = 200) {
return new Response(JSON.stringify(body), {
status,
headers: { "content-type": "application/json" },
});
}
describe("Référentiel IA", () => {
it("lit un jeton serveur depuis un fichier monté sans le journaliser", () => {
const directory = mkdtempSync(join(tmpdir(), "referentiel-ia-"));
const secretFile = join(directory, "token");
try {
writeFileSync(secretFile, " server-only-token\n", { mode: 0o600 });
expect(readSecretFile(secretFile)).toBe("server-only-token");
expect(readSecretFile(join(directory, "absent"))).toBe("");
} finally {
rmSync(directory, { recursive: true, force: true });
}
});
it("utilise le secret monté lorsque la variable d'environnement est vide", () => {
const directory = mkdtempSync(join(tmpdir(), "referentiel-ia-"));
const secretFile = join(directory, "token");
try {
writeFileSync(secretFile, " mounted-secret\n", { mode: 0o600 });
expect(resolveServerSecret("", secretFile)).toBe("mounted-secret");
expect(resolveServerSecret(" ", secretFile)).toBe("mounted-secret");
expect(resolveServerSecret("environment-secret", secretFile)).toBe("environment-secret");
} finally {
rmSync(directory, { recursive: true, force: true });
}
});
it("transmet le slug du moteur décidé avant la requête OpenAI-compatible", async () => {
let receivedUrl = "";
let receivedInit: RequestInit | undefined;
let callCount = 0;
const result = await invokeReferentialIa(
{
messages: [{ role: "user", content: "Extrais la facture" }],
max_tokens: 1200,
},
connection,
async (url, init) => {
callCount += 1;
receivedUrl = String(url);
receivedInit = init;
if (callCount === 1) {
expect(receivedUrl).toBe("https://referentiel-ia-santinova.recette.santinova-soft.org/api/integration/v1/recommendations");
expect(JSON.parse(String(receivedInit?.body))).toEqual({
useCase: "texte",
optimizeCost: true,
needsFreeTier: false,
includeConnection: false,
});
return jsonResponse({
application: { name: "Démat. Facturation DSI" },
decision: { selected: { engine: { name: "SANTINO Expert", slug: "santino-expert" } } },
});
}
return jsonResponse({
id: "ref-1",
created: 1,
model: "selected-by-reference",
choices: [{ index: 0, message: { role: "assistant", content: "{}" }, finish_reason: "stop" }],
});
},
);
expect(receivedUrl).toBe("https://referentiel-ia-santinova.recette.santinova-soft.org/api/integration/v1/invoke");
expect(receivedInit?.headers).toMatchObject({ authorization: "Bearer test-token" });
expect(JSON.parse(String(receivedInit?.body))).toMatchObject({
engineSlug: "santino-expert",
useCase: "texte",
payload: {
messages: [{ role: "user", content: "Extrais la facture" }],
max_tokens: 1200,
stream: false,
},
});
expect(callCount).toBe(2);
expect(result.choices[0]?.message.content).toBe("{}");
});
it("retourne un diagnostic non sensible lorsqu'aucun jeton n'est configuré", async () => {
await expect(
invokeReferentialIa({ messages: [{ role: "user", content: "test" }] }, { baseUrl: "", token: "" }),
).rejects.toThrow("Référentiel IA");
});
it("confirme le moteur choisi sans déclencher d'analyse de facture", async () => {
const result = await testReferentialIaConnection(
connection,
async (_url, init) => {
expect(JSON.parse(String(init?.body))).toEqual({
useCase: "texte",
optimizeCost: true,
needsFreeTier: false,
includeConnection: false,
});
return jsonResponse({
application: { name: "Démat. Facturation DSI" },
decision: { selected: { engine: { name: "Manus Forge", slug: "manus-forge" }, connection: undefined } },
});
},
);
expect(result).toEqual({ applicationName: "Démat. Facturation DSI", engineName: "Manus Forge", model: null });
});
});

156
server/referentialIa.ts Normal file
View File

@@ -0,0 +1,156 @@
import type { InvokeParams, InvokeResult } from "./_core/llm";
import { ENV } from "./_core/env";
export type ReferentielIaConnection = {
baseUrl: string;
token: string;
};
type ReferentielIaRecommendation = {
application?: { name?: unknown };
decision?: {
selected?: {
engine?: { name?: unknown; slug?: unknown };
connection?: { model?: unknown };
} | null;
} | null;
};
type Fetcher = (input: string | URL, init?: RequestInit) => Promise<Response>;
const RECOMMENDATIONS_PATH = "/api/integration/v1/recommendations";
const INVOCATION_PATH = "/api/integration/v1/invoke";
/**
* Le jeton est exclusivement fourni au serveur via l'environnement. Il n'est
* jamais exposé à l'interface ni stocké dans les paramètres de l'utilisateur.
*/
export function getReferentielIaConnection(): ReferentielIaConnection | null {
const baseUrl = ENV.referentielIaUrl.trim().replace(/\/$/, "");
const token = ENV.referentielIaToken.trim();
if (!baseUrl || !token) return null;
try {
const url = new URL(baseUrl);
if (url.protocol !== "https:") return null;
return { baseUrl: url.toString().replace(/\/$/, ""), token };
} catch {
return null;
}
}
function requireConnection(connection?: ReferentielIaConnection | null): ReferentielIaConnection {
if (!connection?.baseUrl.trim() || !connection.token.trim()) {
throw new Error("Le Référentiel IA n'est pas configuré pour cette application.");
}
return connection;
}
async function readJson(response: Response): Promise<unknown> {
const raw = await response.text();
try {
return JSON.parse(raw) as unknown;
} catch {
return raw;
}
}
function toGatewayPayload(params: InvokeParams): Record<string, unknown> {
const payload: Record<string, unknown> = {
messages: params.messages,
max_tokens: params.maxTokens ?? params.max_tokens ?? 32768,
stream: false,
};
if (params.tools?.length) payload.tools = params.tools;
if (params.toolChoice ?? params.tool_choice) payload.tool_choice = params.toolChoice ?? params.tool_choice;
if (params.outputSchema ?? params.output_schema) payload.response_format = {
type: "json_schema",
json_schema: params.outputSchema ?? params.output_schema,
};
if (params.responseFormat ?? params.response_format) payload.response_format = params.responseFormat ?? params.response_format;
return payload;
}
function gatewayError(status: number): Error {
if (status === 401 || status === 403) return new Error("Le Référentiel IA refuse l'accès de cette application.");
if (status === 422) return new Error("Le moteur choisi dans le Référentiel IA n'est pas opérationnel.");
if (status === 429) return new Error("La limite d'usage définie dans le Référentiel IA est atteinte.");
return new Error(`Le Référentiel IA est indisponible (HTTP ${status}).`);
}
/** Demande au Référentiel le moteur actuellement décidé pour cette application. */
async function getReferentialIaRecommendation(
connection: ReferentielIaConnection,
fetcher: Fetcher,
): Promise<ReferentielIaRecommendation> {
const response = await fetcher(`${connection.baseUrl}${RECOMMENDATIONS_PATH}`, {
method: "POST",
headers: {
"content-type": "application/json",
authorization: `Bearer ${connection.token}`,
},
body: JSON.stringify({
useCase: "texte",
optimizeCost: true,
needsFreeTier: false,
includeConnection: false,
}),
signal: AbortSignal.timeout(15_000),
});
if (!response.ok) throw gatewayError(response.status);
return (await readJson(response)) as ReferentielIaRecommendation;
}
function selectedEngineSlug(recommendation: ReferentielIaRecommendation): string {
const slug = recommendation.decision?.selected?.engine?.slug;
if (typeof slug !== "string" || !slug.trim()) {
throw new Error("Le Référentiel IA ne fournit aucun moteur sélectionné pour cette application.");
}
return slug.trim();
}
/** Exécute l'appel sur le moteur décidé et tracé par le Référentiel IA. */
export async function invokeReferentialIa(
params: InvokeParams,
connection = getReferentielIaConnection(),
fetcher: Fetcher = fetch,
): Promise<InvokeResult> {
const config = requireConnection(connection);
const recommendation = await getReferentialIaRecommendation(config, fetcher);
const engineSlug = selectedEngineSlug(recommendation);
const response = await fetcher(`${config.baseUrl}${INVOCATION_PATH}`, {
method: "POST",
headers: {
"content-type": "application/json",
authorization: `Bearer ${config.token}`,
},
body: JSON.stringify({
engineSlug,
useCase: "texte",
payload: toGatewayPayload(params),
}),
signal: AbortSignal.timeout(120_000),
});
if (!response.ok) throw gatewayError(response.status);
return (await readJson(response)) as InvokeResult;
}
export type ReferentielIaConnectionTest = {
applicationName: string | null;
engineName: string | null;
model: string | null;
};
/** Vérifie l'autorisation et le moteur choisi, sans envoyer de facture au modèle. */
export async function testReferentialIaConnection(
connection = getReferentielIaConnection(),
fetcher: Fetcher = fetch,
): Promise<ReferentielIaConnectionTest> {
const config = requireConnection(connection);
const data = await getReferentialIaRecommendation(config, fetcher);
return {
applicationName: typeof data.application?.name === "string" ? data.application.name : null,
engineName: typeof data.decision?.selected?.engine?.name === "string" ? data.decision.selected.engine.name : null,
model: typeof data.decision?.selected?.connection?.model === "string" ? data.decision.selected.connection.model : null,
};
}

View File

@@ -1,5 +1,11 @@
import { z } from "zod";
import { COOKIE_NAME } from "@shared/const";
import { BAP_MIN_QUALITY_SCORE, meetsBapQualityThreshold } from "@shared/bapEligibility";
import { requiresBapBeforeOriginalPdfExport } from "@shared/bapExportGuard";
import { calculateInvoiceQualityScore, withCalculatedInvoiceQuality } from "@shared/invoiceQuality";
import { normalizeImportLogPagination } from "@shared/importLogPagination";
import { getImportLogRetentionCutoff, isImportLogRetentionMonths } from "@shared/importLogRetention";
import { webPortalUrlSchema } from "./webPortalUrl";
interface Condition {
field: string;
@@ -11,6 +17,7 @@ interface Actions {
typeAchat?: string;
serviceConcerne?: string;
ventilationComptable?: string;
isSubscription?: 0 | 1;
}
import { getSessionCookieOptions } from "./_core/cookies";
import { systemRouter } from "./_core/systemRouter";
@@ -24,7 +31,9 @@ import {
searchInvoices,
getInvoiceStats,
createSourceFile,
getSourceFileByContentHash,
getSourceFileById,
countInvoicesBySourceFileId,
updateSourceFile,
getUserSettings,
upsertUserSettings,
@@ -34,16 +43,20 @@ import {
toggleUserActive,
deleteUser,
findDuplicateInvoice,
isInvoiceBlacklisted,
getAllInvoices,
getAllImportLogs,
getAllImportLogsWithDetails,
getAllBapHistory,
createImportLog,
getImportLogsByUser,
getImportLogsWithDetailsByUser,
deleteAllImportLogs,
countImportLogsBefore,
deleteImportLogsBefore,
getLlmLogsBySourceFile,
getLlmLogsByInvoice,
getImportSettingsByUser,
getEmailImportAccounts,
upsertImportSettings,
getDepartmentsByUser,
createDepartment,
@@ -60,6 +73,11 @@ import {
createAutomationRule,
updateAutomationRule,
deleteAutomationRule,
getExportAutomationRulesByUser,
getExportAutomationRuleById,
createExportAutomationRule,
updateExportAutomationRule,
deleteExportAutomationRule,
getSignaturesByUser,
getSignatureById,
createSignature,
@@ -81,17 +99,25 @@ import {
getWebImportSourceById,
createWebImportSource,
updateWebImportSource,
updateWebImportSourceStatus,
deleteWebImportSource,
} from "./db";
import { loginLocal, hashPassword, getAzureAuthUrl, isAzureAdConfigured } from "./auth";
import fsSync from "fs";
import pathSync from "path";
import { extractInvoicesWithMistral, generateMetadataJSON } from "./invoiceExtractor";
import { localStoragePut, generateStorageKey } from "./localStorage";
import { calculateFileSha256 } from "./fileFingerprint";
import { localStoragePut, localStorageExists, generateStorageKey } from "./localStorage";
import { shouldReprocessEmptySource } from "@shared/sourceFileReimport";
import { testReferentialIaConnection } from "./referentialIa";
import { testSftpConnection, exportInvoiceToSftp, getUserSftpConfig } from "./sftpExport";
import { drawBapCartouche } from "./bapCartouche";
import { startEmailImportService, stopEmailImportService, isEmailImportServiceRunning, triggerEmailCheck, testImapConnection } from "./emailImportService";
import { resolveBapExportDestination } from "./exportDestinationResolver";
import { createEmailImportAccountController, startEmailImportService, stopEmailImportService, isEmailImportServiceRunning, triggerEmailCheck, triggerManualEmailCheck, testImapConnection } from "./emailImportService";
import { startFolderImportService, stopFolderImportService, isFolderImportServiceRunning } from "./folderImportService";
import { getSafeWebImportSourceError, logWebImportSourceSaveFailure } from "./webImportSourcesError";
import { getSafeInvoiceDeletionError } from "./invoiceDeletionError";
import { getManualWebImportAvailability, runManualWebImport } from "./manualWebImport";
import { TRPCError } from "@trpc/server";
import { processFreeproExcel } from "./freeproService";
import { runFreeproAutoImport, testFreeproConnection, startFreeproAutoJob, stopFreeproAutoJob, frequencyToMs } from "./freeproAutoImport";
@@ -181,30 +207,67 @@ export const appRouter = router({
// Decode base64 file data
const fileBuffer = Buffer.from(input.fileData, "base64");
console.log(`[Upload] Received file: ${input.fileName}, size: ${fileBuffer.length} bytes`);
// Store source file
const sourceFileKey = generateStorageKey(userId, input.fileName);
console.log(`[Upload] Generated storage key: ${sourceFileKey}`);
// Le contrôle sur les octets du PDF intervient avant le stockage et l'appel IA.
// Il reste fiable même si le nom du fichier ou le compte utilisateur diffère.
const contentHash = calculateFileSha256(fileBuffer);
const existingSource = await getSourceFileByContentHash(contentHash);
let sourceFile;
let sourceFileKey: string;
let sourceFileUrl: string;
try {
const result = await localStoragePut(sourceFileKey, fileBuffer, "application/pdf");
sourceFileUrl = result.url;
console.log(`[Upload] File stored successfully at: ${sourceFileUrl}`);
} catch (error) {
console.error(`[Upload] FAILED to store file:`, error);
throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "Failed to store PDF file" });
let reprocessed = false;
if (existingSource) {
const invoiceCount = await countInvoicesBySourceFileId(existingSource.id);
if (!shouldReprocessEmptySource({
sourceUserId: existingSource.userId,
requestingUserId: userId,
invoiceCount,
})) {
throw new TRPCError({
code: "CONFLICT",
message: `Ce PDF a déjà été importé (${existingSource.fileName}).`,
});
}
// Une purge peut supprimer les factures tout en conservant la source et son empreinte.
// On relance alors l'analyse depuis la source existante plutôt que de contourner l'anti-doublon.
sourceFile = existingSource;
sourceFileKey = existingSource.fileKey;
sourceFileUrl = existingSource.fileUrl;
reprocessed = true;
if (!await localStorageExists(sourceFileKey)) {
await localStoragePut(sourceFileKey, fileBuffer, "application/pdf");
}
await updateSourceFile(sourceFile.id, {
processingStatus: "processing",
totalInvoicesDetected: 0,
processingProgress: "Nouvelle analyse après purge...",
});
} else {
// Store source file
sourceFileKey = generateStorageKey(userId, input.fileName);
console.log(`[Upload] Generated storage key: ${sourceFileKey}`);
try {
const result = await localStoragePut(sourceFileKey, fileBuffer, "application/pdf");
sourceFileUrl = result.url;
console.log(`[Upload] File stored successfully at: ${sourceFileUrl}`);
} catch (error) {
console.error(`[Upload] FAILED to store file:`, error);
throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "Failed to store PDF file" });
}
sourceFile = await createSourceFile({
userId,
fileName: input.fileName,
fileKey: sourceFileKey,
fileUrl: sourceFileUrl,
contentHash,
processingStatus: "processing",
});
}
// Create source file record
const sourceFile = await createSourceFile({
userId,
fileName: input.fileName,
fileKey: sourceFileKey,
fileUrl: sourceFileUrl,
processingStatus: "processing",
});
// Start extraction process (async - don't wait)
(async () => {
try {
@@ -228,6 +291,7 @@ export const appRouter = router({
manusForgeApiUrl: (settings as any).manusForgeApiUrl || null,
geminiApiKey: (settings as any).geminiApiKey || null,
} : undefined;
const qualityFieldsConfig = await getLlmFieldsConfigByUser(userId);
// Extract invoices
const result = await extractInvoicesWithMistral(
@@ -254,6 +318,9 @@ export const appRouter = router({
for (let i = 0; i < result.invoices.length; i++) {
const invoiceData = result.invoices[i]!;
// Le score affiché est déterministe : les champs obligatoires
// configurés prévalent sur l'estimation fournie par le modèle.
const invoiceWithQuality = withCalculatedInvoiceQuality(invoiceData, qualityFieldsConfig);
try {
// Update progress
@@ -261,19 +328,6 @@ export const appRouter = router({
processingProgress: `Extraction ${i + 1}/${result.invoiceCount} factures...`,
});
// Vérifier la blacklist (factures supprimées manuellement)
const blacklisted = await isInvoiceBlacklisted(invoiceData.invoiceNumber, userId);
if (blacklisted) {
duplicatesCount++;
duplicateDetails.push({
supplierName: invoiceData.supplierName,
invoiceNumber: invoiceData.invoiceNumber,
totalAmount: invoiceData.totalAmount,
reason: 'blacklisted',
});
continue;
}
// Check for duplicates (numéro de facture + montant)
const duplicate = await findDuplicateInvoice(
invoiceData.invoiceNumber,
@@ -316,7 +370,7 @@ export const appRouter = router({
totalAmount: invoiceData.totalAmount?.toString(),
recipientName: invoiceData.recipientName,
pageRange: invoiceData.pageRange,
qualityScore: invoiceData.qualityScore,
qualityScore: invoiceWithQuality.qualityScore,
extractedText: invoiceData.extractedText,
isSubscription: invoiceData.isSubscription ? 1 : 0,
metadataFileKey: metadataKey,
@@ -393,6 +447,7 @@ export const appRouter = router({
duplicateDetails: JSON.stringify(duplicateDetails),
errorDetails: JSON.stringify(errorDetails),
importSource: "file",
importTrigger: "manual",
});
} catch (error: any) {
@@ -406,7 +461,7 @@ export const appRouter = router({
}
})();
return { sourceFileId: sourceFile.id };
return { sourceFileId: sourceFile.id, reprocessed };
}),
list: protectedProcedure.query(async ({ ctx }) => {
@@ -450,13 +505,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
@@ -467,7 +532,18 @@ export const appRouter = router({
throw new TRPCError({ code: "NOT_FOUND" });
}
await deleteInvoice(input.id);
try {
await deleteInvoice(input.id);
} catch (error) {
console.error("[InvoiceDelete] Archive or deletion failed", {
invoiceId: input.id,
error: error instanceof Error ? error.message : String(error),
});
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: getSafeInvoiceDeletionError(error),
});
}
return { success: true };
}),
@@ -484,8 +560,8 @@ export const appRouter = router({
const hasService = !!invoice.serviceConcerne;
const hasTypeAchat = !!invoice.typeAchat;
const hasVentilation = !!invoice.ventilationComptable;
if (score < 100) {
throw new TRPCError({ code: "BAD_REQUEST", message: "Le score de qualité doit être à 100% pour valider" });
if (!meetsBapQualityThreshold(score)) {
throw new TRPCError({ code: "BAD_REQUEST", message: `Le score de qualité doit être au moins de ${BAP_MIN_QUALITY_SCORE}% pour valider` });
}
if (!isNotSubscription) {
throw new TRPCError({ code: "BAD_REQUEST", message: "La facture est marquée comme abonnement" });
@@ -501,9 +577,10 @@ export const appRouter = router({
const { localStoragePut, generateStorageKey } = await import('./localStorage');
const importSettings = await getImportSettingsByUser(ctx.user.id);
const bapExportMode = importSettings?.bapExportMode || 'browser';
const exportFolder = importSettings?.exportFolder || null;
const exportFolderType = (importSettings as any)?.exportFolderType || 'local';
const resolvedDestination = await resolveBapExportDestination(ctx.user.id, invoice, importSettings);
const bapExportMode = resolvedDestination.exportMode;
const exportFolder = resolvedDestination.destinationPath;
const exportFolderType = resolvedDestination.destinationType;
const STORAGE_BASE_PATH = process.env.STORAGE_BASE_PATH || path.join(process.cwd(), 'storage');
let pdfUrl: string | null = null;
@@ -589,11 +666,11 @@ export const appRouter = router({
const _bapNumber = (invoice.invoiceNumber || '').replace(/[^a-zA-Z0-9\-]/g, '').trim();
const bapFilename = [_bapDateStr, _bapSupplier, _bapNumber].filter(Boolean).join(' - ') + '.pdf';
console.log(`[BAP] Mode export: ${bapExportMode}, type: ${exportFolderType}, dossier: ${exportFolder ? 'configuré' : 'non configuré'}`);
console.log(`[BAP] Mode export: ${bapExportMode}, type: ${exportFolderType}, source: ${resolvedDestination.source}, dossier: ${exportFolder ? 'configuré' : 'non configuré'}`);
if ((bapExportMode === 'folder' || bapExportMode === 'both') && exportFolder) {
if (exportFolderType === 'sharepoint') {
// Mode SharePoint : upload via Microsoft Graph
console.log('[BAP] Démarrage upload SharePoint pour:', bapFilename);
if (exportFolderType === 'sharepoint' || exportFolderType === 'teams') {
// Les fichiers Teams sont déposés via le site SharePoint de l’équipe.
console.log('[BAP] Démarrage upload Microsoft 365 pour:', bapFilename);
const { uploadToSharePoint } = await import('./sharepoint');
const spResult = await uploadToSharePoint(
{
@@ -705,7 +782,7 @@ export const appRouter = router({
const allInvoices = ctx.user.role === 'admin' ? await getAllInvoices() : await getInvoicesByUser(ctx.user.id);
// Filtrer les factures éligibles (non déjà validées)
const eligible = allInvoices.filter((inv: any) =>
(inv.qualityScore || 0) >= 100 &&
meetsBapQualityThreshold(inv.qualityScore) &&
inv.isSubscription === 0 &&
!!inv.serviceConcerne &&
!!inv.typeAchat &&
@@ -720,9 +797,6 @@ export const appRouter = router({
const { PDFDocument, rgb, StandardFonts } = await import('pdf-lib');
const { localStoragePut, generateStorageKey } = await import('./localStorage');
const importSettings = await getImportSettingsByUser(ctx.user.id);
const bapExportMode = importSettings?.bapExportMode || 'browser';
const exportFolder = importSettings?.exportFolder || null;
const exportFolderType = (importSettings as any)?.exportFolderType || 'local';
const STORAGE_BASE_PATH = process.env.STORAGE_BASE_PATH || path.join(process.cwd(), 'storage');
const serviceSignaturesList = await getServiceSignaturesByUser(ctx.user.id);
const results: Array<{ id: number; success: boolean; pdfUrl?: string | null; exportPath?: string | null; error?: string }> = [];
@@ -736,6 +810,10 @@ export const appRouter = router({
let sharepointUploadStatus: 'success' | 'error' | 'skipped' | null = null;
let sharepointUploadPath: string | null = null;
let sharepointUploadError: string | null = null;
const resolvedDestination = await resolveBapExportDestination(ctx.user.id, invoice, importSettings);
const bapExportMode = resolvedDestination.exportMode;
const exportFolder = resolvedDestination.destinationPath;
const exportFolderType = resolvedDestination.destinationType;
try {
// ─ Lecture du PDF source ─
let sourcePdfBytes: Buffer;
@@ -806,7 +884,7 @@ export const appRouter = router({
const _bapNumber2 = (invoice.invoiceNumber || '').replace(/[^a-zA-Z0-9\-]/g, '').trim();
const bapFilename = [_bapDateStr2, _bapSupplier2, _bapNumber2].filter(Boolean).join(' - ') + '.pdf';
if ((bapExportMode === 'folder' || bapExportMode === 'both') && exportFolder) {
if (exportFolderType === 'sharepoint') {
if (exportFolderType === 'sharepoint' || exportFolderType === 'teams') {
const { uploadToSharePoint } = await import('./sharepoint');
const spResult = await uploadToSharePoint(
{
@@ -1029,7 +1107,8 @@ export const appRouter = router({
}),
getStats: protectedProcedure.query(async ({ ctx }) => {
return getInvoiceStats(ctx.user.id);
// Les administrateurs consultent les mêmes données globales que les listes Factures.
return getInvoiceStats(ctx.user.role === "admin" ? undefined : ctx.user.id);
}),
}),
@@ -1193,7 +1272,7 @@ export const appRouter = router({
sftpAutoExport: z.number().optional(),
llmLogsRetentionMonths: z.number().optional(),
learningConfidenceThreshold: z.number().min(1).optional(),
aiProvider: z.enum(["mistral", "manus", "gemini"]).optional(),
aiProvider: z.enum(["mistral", "manus", "gemini", "referentiel"]).optional(),
mistralApiKey: z.string().optional(),
manusForgeApiKey: z.string().optional(),
manusForgeApiUrl: z.string().optional(),
@@ -1206,6 +1285,12 @@ export const appRouter = router({
});
return { success: true };
}),
/** Vérifie le choix centralisé sans transmettre de facture ni de clé au navigateur. */
testReferentielIa: protectedProcedure.mutation(async () => {
const result = await testReferentialIaConnection();
return { success: true, ...result };
}),
}),
// ============= ADMIN ROUTES =============
@@ -1305,22 +1390,13 @@ export const appRouter = router({
const path = await import('path');
const { PDFDocument } = await import('pdf-lib');
// Get export folder from settings
// Les paramètres globaux restent le repli lorsqu’aucune règle de
// destination ne cible une facture exportée.
const settings = await getImportSettingsByUser(ctx.user.id);
const exportFolder = settings?.exportFolder;
const exportFolderType = (settings as any)?.exportFolderType || 'local';
const isSharePoint = exportFolderType === 'sharepoint';
// Load service→signature associations
const serviceAssociations = await getServiceSignaturesByUser(ctx.user.id);
if (!exportFolder) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Le dossier d'export n'est pas configuré. Veuillez le définir dans les paramètres de réception."
});
}
// Validate that all invoices have quality score of 100
const invoices = await Promise.all(
input.invoiceIds.map(id => getInvoiceById(id))
@@ -1336,30 +1412,46 @@ export const appRouter = router({
message: "Toutes les factures doivent avoir un score de qualité de 100% pour être exportées"
});
}
// Create export folder if not SharePoint
if (!isSharePoint) {
try {
await fs.mkdir(exportFolder, { recursive: true });
} catch (error) {
console.error('Error creating export folder:', error);
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: `Impossible de créer le dossier d'export: ${exportFolder}`
});
}
// Un PDF hors abonnement doit suivre le circuit BAP. Sans ce contrôle
// serveur, l'export manuel contournait la validation BAP puis rendait
// l'état métier incohérent dans la liste des factures.
const invoicesAwaitingBap = invoices.filter(
(invoice) => invoice && requiresBapBeforeOriginalPdfExport(invoice),
);
if (invoicesAwaitingBap.length > 0) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "La validation BAP est requise avant l'export PDF des factures hors abonnement.",
});
}
console.log(`[exportToPdf] Mode: ${exportFolderType}, dossier: ${exportFolder}, ${invoices.length} facture(s)`);
// Copy/upload PDFs to export folder
// La destination est résolue facture par facture : une sélection peut
// contenir plusieurs abonnements avec des règles de priorité distinctes.
const copiedFiles: string[] = [];
const errors: string[] = [];
const usedDestinations = new Set<string>();
const STORAGE_BASE_PATH = process.env.STORAGE_BASE_PATH || path.join(process.cwd(), "storage");
for (const invoice of invoices) {
if (invoice && invoice.fileKey) {
try {
const resolvedDestination = await resolveBapExportDestination(ctx.user.id, invoice, settings);
const exportFolder = resolvedDestination.destinationPath;
const exportFolderType = resolvedDestination.destinationType;
const isMicrosoftDestination = exportFolderType === "sharepoint" || exportFolderType === "teams";
if (!exportFolder) {
throw new Error("Aucune destination d'export n'est configurée pour cette facture");
}
if (!isMicrosoftDestination) {
await fs.mkdir(exportFolder, { recursive: true });
}
usedDestinations.add(exportFolder);
console.log(`[exportToPdf] Facture ${invoice.id}: ${resolvedDestination.source}/${exportFolderType} vers ${exportFolder}`);
// Build source path from fileKey
const sourcePath = path.join(STORAGE_BASE_PATH, invoice.fileKey);
@@ -1445,7 +1537,7 @@ export const appRouter = router({
const signedPdfBytes = await pdfDoc.save();
console.log(`[Export] Signature apposée pour ${serviceName} sur ${filename}`);
if (isSharePoint) {
if (isMicrosoftDestination) {
const { uploadToSharePoint } = await import('./sharepoint');
console.log(`[exportToPdf] Upload SharePoint: ${filename}`);
const spResult = await uploadToSharePoint(
@@ -1468,7 +1560,7 @@ export const appRouter = router({
const rb = await fetch(au); if (!rb.ok) throw new Error(`HTTP ${rb.status}`);
pdfBuf = Buffer.from(await rb.arrayBuffer());
}
if (isSharePoint) {
if (isMicrosoftDestination) {
const { uploadToSharePoint } = await import('./sharepoint');
const spResult = await uploadToSharePoint(
{ tenantId: (settings as any)?.azureTenantId || '', clientId: (settings as any)?.azureClientId || '', clientSecret: (settings as any)?.azureClientSecret || '', sharepointUrl: exportFolder },
@@ -1490,7 +1582,7 @@ export const appRouter = router({
const rb = await fetch(au); if (!rb.ok) throw new Error(`HTTP ${rb.status}`);
pdfBuf = Buffer.from(await rb.arrayBuffer());
}
if (isSharePoint) {
if (isMicrosoftDestination) {
const { uploadToSharePoint } = await import('./sharepoint');
const spResult = await uploadToSharePoint(
{ tenantId: (settings as any)?.azureTenantId || '', clientId: (settings as any)?.azureClientId || '', clientSecret: (settings as any)?.azureClientSecret || '', sharepointUrl: exportFolder },
@@ -1512,7 +1604,7 @@ export const appRouter = router({
const rb = await fetch(au); if (!rb.ok) throw new Error(`HTTP ${rb.status}`);
pdfBuf = Buffer.from(await rb.arrayBuffer());
}
if (isSharePoint) {
if (isMicrosoftDestination) {
const { uploadToSharePoint } = await import('./sharepoint');
console.log(`[exportToPdf] Upload SharePoint sans signature: ${filename}`);
const spResult = await uploadToSharePoint(
@@ -1556,7 +1648,7 @@ export const appRouter = router({
return {
success: true,
exportFolder,
exportFolder: Array.from(usedDestinations).join(", "),
copiedCount: copiedFiles.length,
invoices: invoices.filter(Boolean).map(inv => ({
id: inv!.id,
@@ -1575,6 +1667,19 @@ export const appRouter = router({
throw new TRPCError({ code: "BAD_REQUEST", message: "SFTP not configured" });
}
const selectedInvoices = await Promise.all(input.invoiceIds.map(id => getInvoiceById(id)));
const invoicesAwaitingBap = selectedInvoices.filter(
(invoice) => invoice
&& (ctx.user.role === "admin" || invoice.userId === ctx.user.id)
&& requiresBapBeforeOriginalPdfExport(invoice),
);
if (invoicesAwaitingBap.length > 0) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "La validation BAP est requise avant l'export SFTP des factures hors abonnement.",
});
}
// Load user settings to check recipient filter
const userSettings = await getUserSettings(ctx.user.id);
const recipientFilter = (userSettings as any)?.sftpRecipientFilter?.trim() || "";
@@ -1626,12 +1731,15 @@ export const appRouter = router({
// ============= IMPORT LOGS ROUTES =============
importLogs: router({
getByUser: protectedProcedure.query(async ({ ctx }) => {
getByUser: protectedProcedure
.input(z.object({ limit: z.number().int().positive().optional(), offset: z.number().int().min(0).optional() }).optional())
.query(async ({ ctx, input }) => {
const pagination = normalizeImportLogPagination(input);
// Les admins voient tous les logs d'import
if (ctx.user.role === 'admin') {
return getAllImportLogs();
return getAllImportLogsWithDetails(pagination);
}
return getImportLogsByUser(ctx.user.id);
return getImportLogsWithDetailsByUser(ctx.user.id, pagination);
}),
deleteAll: protectedProcedure.mutation(async ({ ctx }) => {
@@ -1643,6 +1751,34 @@ export const appRouter = router({
}
return { success: true };
}),
retentionPreview: protectedProcedure
.input(z.object({ months: z.number().int() }))
.query(async ({ ctx, input }) => {
if (ctx.user.role !== "admin") {
throw new TRPCError({ code: "FORBIDDEN", message: "Réservé aux administrateurs." });
}
if (!isImportLogRetentionMonths(input.months)) {
throw new TRPCError({ code: "BAD_REQUEST", message: "Durée de rétention invalide." });
}
const cutoff = getImportLogRetentionCutoff(input.months);
const count = await countImportLogsBefore(cutoff);
return { months: input.months, cutoff, count };
}),
purgeBeforeRetention: protectedProcedure
.input(z.object({ months: z.number().int() }))
.mutation(async ({ ctx, input }) => {
if (ctx.user.role !== "admin") {
throw new TRPCError({ code: "FORBIDDEN", message: "Réservé aux administrateurs." });
}
if (!isImportLogRetentionMonths(input.months)) {
throw new TRPCError({ code: "BAD_REQUEST", message: "Durée de rétention invalide." });
}
const cutoff = getImportLogRetentionCutoff(input.months);
const deletedCount = await deleteImportLogsBefore(cutoff);
return { months: input.months, cutoff, deletedCount };
}),
}),
// ============= LLM LOGS ROUTES =============
@@ -1681,8 +1817,7 @@ export const appRouter = router({
// ============= EMAIL IMPORT SERVICE ROUTES =============
emailImportService: router({
start: protectedProcedure.mutation(async ({ ctx }) => {
const started = await startEmailImportService(ctx.user.id);
return { success: started };
return startEmailImportService(ctx.user.id);
}),
stop: protectedProcedure.mutation(async ({ ctx }) => {
@@ -1698,6 +1833,39 @@ export const appRouter = router({
const result = await triggerEmailCheck(ctx.user.id);
return result;
}),
/** Liste sans secrets les boîtes IMAP disponibles pour une action manuelle d’administrateur. */
getConfiguredAccounts: adminProcedure.query(async () => {
return getEmailImportAccounts();
}),
/** Démarre la réception automatique pour la boîte source sélectionnée par un administrateur. */
startAccount: adminProcedure
.input(z.object({ userId: z.number().int().positive() }))
.mutation(async ({ input }) => {
return createEmailImportAccountController().startAccount(input.userId);
}),
/** Arrête la réception automatique pour la boîte source sélectionnée par un administrateur. */
stopAccount: adminProcedure
.input(z.object({ userId: z.number().int().positive() }))
.mutation(async ({ input }) => {
return createEmailImportAccountController().stopAccount(input.userId);
}),
/** Retourne le statut en mémoire d’une boîte source administrée. */
accountStatus: adminProcedure
.input(z.object({ userId: z.number().int().positive() }))
.query(async ({ input }) => {
return createEmailImportAccountController().accountStatus(input.userId);
}),
/** Déclenche une seule lecture IMAP sans activer ni planifier le service automatique. */
checkAccountNow: adminProcedure
.input(z.object({ userId: z.number().int().positive() }))
.mutation(async ({ input }) => {
return triggerManualEmailCheck(input.userId);
}),
}),
// ============= IMPORT SETTINGS ROUTES =============
@@ -1725,6 +1893,7 @@ export const appRouter = router({
azureTenantId: null,
azureClientId: null,
azureClientSecret: null,
azureSecretExpiresAt: null,
};
}
@@ -2014,7 +2183,7 @@ export const appRouter = router({
for (const invoice of bapInvoices) {
const updates = await applyAutomationRules(ctx.user.id, invoice);
if (updates.typeAchat || updates.serviceConcerne || updates.ventilationComptable || updates.autoFilledFields) {
if (updates.typeAchat || updates.serviceConcerne || updates.ventilationComptable || updates.isSubscription !== undefined || updates.autoFilledFields) {
await updateInvoice(invoice.id, updates);
}
}
@@ -2045,7 +2214,7 @@ export const appRouter = router({
for (const invoice of bapInvoices) {
const updates = await applyAutomationRules(ctx.user.id, invoice);
if (updates.typeAchat || updates.serviceConcerne || updates.ventilationComptable || updates.autoFilledFields) {
if (updates.typeAchat || updates.serviceConcerne || updates.ventilationComptable || updates.isSubscription !== undefined || updates.autoFilledFields) {
await updateInvoice(invoice.id, updates);
}
}
@@ -2243,6 +2412,55 @@ export const appRouter = router({
}),
}),
// ============= EXPORT AUTOMATION RULES =============
exportAutomationRules: router({
list: protectedProcedure.query(async ({ ctx }) => {
return getExportAutomationRulesByUser(ctx.user.id);
}),
create: protectedProcedure
.input(z.object({
name: z.string().trim().min(1).max(255),
conditionField: z.enum(["recipientName", "ventilationComptable"]),
conditionValue: z.string().trim().min(1).max(255),
destinationType: z.enum(["local", "teams", "sharepoint"]),
destinationPath: z.string().trim().min(1),
openInBrowser: z.number().int().min(0).max(1).default(0),
isActive: z.number().int().min(0).max(1).default(1),
priority: z.number().int().min(0).default(0),
}))
.mutation(async ({ input, ctx }) => createExportAutomationRule({ userId: ctx.user.id, ...input })),
update: protectedProcedure
.input(z.object({
id: z.number().int().positive(),
name: z.string().trim().min(1).max(255).optional(),
conditionField: z.enum(["recipientName", "ventilationComptable"]).optional(),
conditionValue: z.string().trim().min(1).max(255).optional(),
destinationType: z.enum(["local", "teams", "sharepoint"]).optional(),
destinationPath: z.string().trim().min(1).optional(),
openInBrowser: z.number().int().min(0).max(1).optional(),
isActive: z.number().int().min(0).max(1).optional(),
priority: z.number().int().min(0).optional(),
}))
.mutation(async ({ input, ctx }) => {
const existing = await getExportAutomationRuleById(input.id);
if (!existing || (ctx.user.role !== "admin" && existing.userId !== ctx.user.id)) {
throw new TRPCError({ code: "NOT_FOUND" });
}
const { id, ...data } = input;
return updateExportAutomationRule(id, data);
}),
delete: protectedProcedure
.input(z.object({ id: z.number().int().positive() }))
.mutation(async ({ input, ctx }) => {
const existing = await getExportAutomationRuleById(input.id);
if (!existing || (ctx.user.role !== "admin" && existing.userId !== ctx.user.id)) {
throw new TRPCError({ code: "NOT_FOUND" });
}
await deleteExportAutomationRule(input.id);
return { success: true };
}),
}),
// ============= SIGNATURES ROUTES =============
signatures: router({
list: protectedProcedure.query(async ({ ctx }) => {
@@ -2627,14 +2845,21 @@ export const appRouter = router({
.input(z.object({
name: z.string().min(1).max(100),
connectorType: z.string().min(1).max(50),
portalUrl: z.string().url(),
portalUrl: webPortalUrlSchema,
loginEmail: z.string().min(1),
loginPassword: z.string().min(1),
frequency: z.enum(['manual', 'daily', 'weekly', 'monthly']).default('monthly'),
autoEnabled: z.number().min(0).max(1).default(0),
}))
.mutation(async ({ input, ctx }) => {
return createWebImportSource({ ...input, userId: ctx.user.id });
try {
return await createWebImportSource({ ...input, userId: ctx.user.id });
} catch (error) {
// Les erreurs MySQL/Drizzle peuvent contenir la requête et les paramètres saisis.
// Elles restent masquées côté navigateur.
logWebImportSourceSaveFailure(error);
throw getSafeWebImportSourceError(error);
}
}),
update: protectedProcedure
@@ -2642,7 +2867,7 @@ export const appRouter = router({
id: z.number(),
name: z.string().min(1).max(100).optional(),
connectorType: z.string().min(1).max(50).optional(),
portalUrl: z.string().url().optional(),
portalUrl: webPortalUrlSchema.optional(),
loginEmail: z.string().min(1).optional(),
loginPassword: z.string().optional(),
frequency: z.enum(['manual', 'daily', 'weekly', 'monthly']).optional(),
@@ -2678,6 +2903,38 @@ export const appRouter = router({
}
return { apiToken: source.apiToken };
}),
/** Lance ponctuellement un connecteur pris en charge, sans activer de planification. */
runManual: protectedProcedure
.input(z.object({ id: z.number() }))
.mutation(async ({ input, ctx }) => {
const source = await getWebImportSourceById(input.id);
if (!source || source.userId !== ctx.user.id) {
throw new TRPCError({ code: "NOT_FOUND", message: "Source introuvable" });
}
const availability = getManualWebImportAvailability(source.connectorType);
if (!availability.available) {
throw new TRPCError({ code: "BAD_REQUEST", message: availability.reason });
}
try {
const result = await runManualWebImport(source);
return { success: true, ...result };
} catch (error) {
// Ne jamais inclure une erreur de portail dans l’interface : elle peut contenir
// des informations de session ou des sélecteurs internes.
const technicalCode = error && typeof error === "object" && "code" in error && typeof error.code === "string"
? error.code
: "unknown";
console.error("[WebImport] Échec d’import manuel", { sourceId: source.id, technicalCode });
await updateWebImportSourceStatus(source.id, "Erreur lors de l’import manuel", 0, false);
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "L’import manuel n’a pas abouti. Consultez le statut du connecteur puis réessayez.",
});
}
}),
}),
// ============= BACKUP ROUTES =============

View File

@@ -0,0 +1,31 @@
import { describe, expect, it } from "vitest";
import {
attachLocallyExtractedText,
buildInvoiceExtractionPrompt,
} from "./invoiceExtractor";
describe("contrat compact pour les moteurs à sortie limitée", () => {
it("ne demande pas au modèle de recopier le texte complet du PDF", () => {
const prompt = buildInvoiceExtractionPrompt({
pageCount: 1,
qualityScoreInstruction: "- qualityScore: score",
subscriptionInstruction: "- isSubscription: false",
keywordsHint: "",
});
expect(prompt).not.toContain("- extractedText:");
expect(prompt).toContain("Ne retourne jamais le texte complet du PDF");
});
it("conserve le texte extrait localement après le JSON métier", () => {
const text = "texte extrait localement";
const result = attachLocallyExtractedText({
pageCount: 1,
invoiceCount: 1,
invoices: [{ invoiceDate: "2026-08-28", supplierName: "Fournisseur" }],
} as any, text);
expect(result.invoices[0].extractedText).toBe(text);
expect(result.invoices[0].invoiceDate).toEqual(new Date("2026-08-28"));
});
});

View File

@@ -0,0 +1,22 @@
import { describe, expect, it } from "vitest";
import { shouldReprocessEmptySource } from "@shared/sourceFileReimport";
describe("shouldReprocessEmptySource", () => {
it("réutilise une source du même utilisateur lorsqu'une purge a supprimé toutes ses factures", () => {
expect(
shouldReprocessEmptySource({ sourceUserId: 2, requestingUserId: 2, invoiceCount: 0 }),
).toBe(true);
});
it("conserve la protection contre les doublons quand la source contient déjà une facture", () => {
expect(
shouldReprocessEmptySource({ sourceUserId: 2, requestingUserId: 2, invoiceCount: 1 }),
).toBe(false);
});
it("ne réutilise jamais une source appartenant à un autre utilisateur", () => {
expect(
shouldReprocessEmptySource({ sourceUserId: 3, requestingUserId: 2, invoiceCount: 0 }),
).toBe(false);
});
});

View File

@@ -0,0 +1,29 @@
import { describe, expect, it } from "vitest";
import {
WEB_IMPORT_SOURCE_SAVE_ERROR,
getSafeWebImportSourceError,
getWebImportSourceTechnicalCode,
} from "./webImportSourcesError";
describe("getSafeWebImportSourceError", () => {
it("never returns the SQL statement or connector parameters to the client", () => {
const databaseError = new Error(
"Failed query: insert into `webImportSources` (...) values (...) params: amazon@example.fr, secret-value",
);
const safeError = getSafeWebImportSourceError(databaseError);
expect(safeError.code).toBe("INTERNAL_SERVER_ERROR");
expect(safeError.message).toBe(WEB_IMPORT_SOURCE_SAVE_ERROR);
expect(safeError.message).not.toContain("insert into");
expect(safeError.message).not.toContain("secret-value");
});
it("retient uniquement le code du pilote, même lorsque celui-ci est imbriqué", () => {
const databaseError = Object.assign(new Error("params: user@example.fr, secret-value"), {
cause: { code: "ER_NO_SUCH_TABLE", message: "contains secret-value" },
});
expect(getWebImportSourceTechnicalCode(databaseError)).toBe("ER_NO_SUCH_TABLE");
});
});

View File

@@ -0,0 +1,37 @@
import { TRPCError } from "@trpc/server";
export const WEB_IMPORT_SOURCE_SAVE_ERROR =
"Impossible d’enregistrer le connecteur. Vérifiez les informations saisies puis réessayez.";
/**
* Masque toute erreur Drizzle/MySQL avant son retour au navigateur : les erreurs
* de pilote peuvent contenir la requête SQL et les paramètres de connexion.
*/
export function getSafeWebImportSourceError(_error: unknown): TRPCError {
return new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: WEB_IMPORT_SOURCE_SAVE_ERROR,
});
}
/** Extrait uniquement le code du pilote, sans conserver son message ni ses paramètres. */
export function getWebImportSourceTechnicalCode(error: unknown): string {
if (!error || typeof error !== "object") return "unknown";
const directCode = "code" in error && typeof error.code === "string" ? error.code : null;
if (directCode) return directCode;
const cause = "cause" in error ? error.cause : null;
if (cause && typeof cause === "object" && "code" in cause && typeof cause.code === "string") {
return cause.code;
}
return "unknown";
}
/** Journalise uniquement les métadonnées techniques non sensibles. */
export function logWebImportSourceSaveFailure(error: unknown): void {
const technicalCode = getWebImportSourceTechnicalCode(error);
console.error("[WebImportSources] Échec d’enregistrement", { technicalCode });
}

View File

@@ -0,0 +1,22 @@
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import { describe, expect, it } from "vitest";
const migration = readFileSync(
resolve(process.cwd(), "drizzle/0036_broken_rattler.sql"),
"utf8",
);
describe("webImportSources migration", () => {
it("conserve l'instruction historique enregistrée", () => {
expect(migration).toMatch(/CREATE TABLE `webImportSources`/);
expect(migration).not.toMatch(/IF NOT EXISTS/);
});
it("définit les colonnes nécessaires au connecteur sans modifier les factures", () => {
expect(migration).toContain("`portalUrl` varchar(500) NOT NULL");
expect(migration).toContain("`loginPassword` text NOT NULL");
expect(migration).toContain("`apiToken` varchar(128) NOT NULL");
expect(migration).not.toContain("invoices");
});
});

View File

@@ -0,0 +1,49 @@
import { describe, expect, it } from "vitest";
import { isValidWebPortalUrl, normalizeWebPortalUrl } from "@shared/webPortalUrl";
import { webPortalUrlSchema } from "./webPortalUrl";
describe("normalizeWebPortalUrl", () => {
it("ajoute HTTPS aux adresses de portail saisies sans protocole", () => {
expect(normalizeWebPortalUrl("www.sfr.fr/mon-espace-client")).toBe(
"https://www.sfr.fr/mon-espace-client",
);
});
it("retire les espaces sans modifier une URL HTTPS déjà valide", () => {
expect(normalizeWebPortalUrl(" https://pro.free.fr/ ")).toBe("https://pro.free.fr/");
});
it("ne transforme pas un protocole non web en URL HTTPS", () => {
expect(normalizeWebPortalUrl("ftp://example.org/factures")).toBe("ftp://example.org/factures");
});
});
describe("isValidWebPortalUrl", () => {
it("accepte exclusivement les URLs HTTP ou HTTPS avec un hôte", () => {
expect(isValidWebPortalUrl("https://espaceclient.orange.fr/")).toBe(true);
expect(isValidWebPortalUrl("http://intranet.local/factures")).toBe(true);
});
it("refuse les valeurs vides, incomplètes ou avec un protocole non web", () => {
expect(isValidWebPortalUrl("")).toBe(false);
expect(isValidWebPortalUrl("https://")).toBe(false);
expect(isValidWebPortalUrl("ftp://example.org/factures")).toBe(false);
});
});
describe("webPortalUrlSchema", () => {
it("normalise l’URL reçue par l’API avant de la persister", () => {
expect(webPortalUrlSchema.parse("espaceclient.orange.fr")).toBe(
"https://espaceclient.orange.fr",
);
});
it("retourne un message métier pour une URL de portail non HTTP(S)", () => {
const result = webPortalUrlSchema.safeParse("ftp://example.org/factures");
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.issues[0]?.message).toContain("URL HTTP(S) valide");
}
});
});

13
server/webPortalUrl.ts Normal file
View File

@@ -0,0 +1,13 @@
import { z } from "zod";
import { isValidWebPortalUrl, normalizeWebPortalUrl } from "@shared/webPortalUrl";
/**
* Normalise les domaines saisis sans protocole puis impose une URL HTTP(S).
* Le même schéma est utilisé à la création et à la mise à jour d’un connecteur.
*/
export const webPortalUrlSchema = z
.string()
.transform(normalizeWebPortalUrl)
.refine(isValidWebPortalUrl, {
message: "Saisissez une URL HTTP(S) valide, par exemple https://espaceclient.exemple.fr.",
});

View File

@@ -0,0 +1,51 @@
/** Les catégories d’actions proposées dans les règles d’automatisme. */
export const AUTOMATION_ACTION_FILTERS = [
{ value: "all", label: "Toutes les actions" },
{ value: "typeAchat", label: "Type d’achat" },
{ value: "serviceConcerne", label: "Service concerné" },
{ value: "ventilationComptable", label: "Ventilation comptable" },
{ value: "subscription", label: "Abonnement (Oui ou Non)" },
{ value: "subscriptionYes", label: "Abonnement : Oui" },
{ value: "subscriptionNo", label: "Abonnement : Non" },
] as const;
export type AutomationActionFilter = (typeof AUTOMATION_ACTION_FILTERS)[number]["value"];
type RuleActions = Record<string, unknown>;
/**
* Lit défensivement le JSON stocké en base : une règle historique invalide ne
* doit jamais empêcher l’affichage ni le filtrage de la liste complète.
*/
function parseRuleActions(actionsJson: string): RuleActions {
try {
const parsed: unknown = JSON.parse(actionsJson);
return parsed !== null && typeof parsed === "object" && !Array.isArray(parsed)
? parsed as RuleActions
: {};
} catch {
return {};
}
}
function getSubscriptionValue(actions: RuleActions): 0 | 1 | undefined {
const value = actions.isSubscription;
if (value === 1 || value === "1" || value === "OUI") return 1;
if (value === 0 || value === "0" || value === "NON") return 0;
return undefined;
}
/** Retourne si les actions JSON d’une règle correspondent au filtre choisi. */
export function matchesAutomationActionFilter(
actionsJson: string,
filter: AutomationActionFilter,
): boolean {
if (filter === "all") return true;
const actions = parseRuleActions(actionsJson);
if (filter === "subscription") return Object.hasOwn(actions, "isSubscription");
if (filter === "subscriptionYes") return getSubscriptionValue(actions) === 1;
if (filter === "subscriptionNo") return getSubscriptionValue(actions) === 0;
return typeof actions[filter] === "string" && actions[filter].trim().length > 0;
}

10
shared/bapEligibility.ts Normal file
View File

@@ -0,0 +1,10 @@
/** Score minimal requis pour générer et valider un BAP. */
export const BAP_MIN_QUALITY_SCORE = 90;
/**
* Centralise le seuil BAP afin que l’interface et le serveur appliquent la
* même règle métier, y compris pour les scores nuls ou absents.
*/
export function meetsBapQualityThreshold(score: number | null | undefined): boolean {
return typeof score === "number" && Number.isFinite(score) && score >= BAP_MIN_QUALITY_SCORE;
}

26
shared/bapExportGuard.ts Normal file
View File

@@ -0,0 +1,26 @@
/**
* Une facture hors abonnement doit être validée BAP avant l'export de son PDF
* d'origine. Les abonnements ne suivent pas le circuit BAP.
*/
export function requiresBapBeforeOriginalPdfExport(input: {
isSubscription?: number | boolean | null;
bapValidated?: number | boolean | null;
}): boolean {
const isSubscription = input.isSubscription === 1 || input.isSubscription === true;
const isBapValidated = input.bapValidated === 1 || input.bapValidated === true;
return !isSubscription && !isBapValidated;
}
/**
* Signale un export historique réalisé avant la validation BAP. Ce cas est
* conservé pour l'audit, mais l'interface doit rendre la régularisation BAP
* possible et explicite.
*/
export function wasExportedBeforeBap(input: {
exportStatus?: string | null;
isSubscription?: number | boolean | null;
bapValidated?: number | boolean | null;
}): boolean {
return input.exportStatus === "exported" && requiresBapBeforeOriginalPdfExport(input);
}

View File

@@ -0,0 +1,32 @@
/** Valeurs nécessaires pour déterminer une destination d’export. */
export type ExportableInvoice = {
recipientName?: string | null;
ventilationComptable?: string | null;
};
export type ExportRuleCandidate = {
isActive: number;
priority: number;
conditionField: "recipientName" | "ventilationComptable";
conditionValue: string;
};
/**
* Sélectionne la première règle active par priorité. Les comparaisons sont
* volontairement insensibles à la casse et aux espaces superflus.
*/
export function findMatchingExportRule<T extends ExportRuleCandidate>(
rules: T[],
invoice: ExportableInvoice,
): T | undefined {
const normalized = (value: string | null | undefined) => value?.trim().toLocaleLowerCase("fr-FR") || "";
const ordered = [...rules].sort((a, b) => a.priority - b.priority || a.conditionValue.localeCompare(b.conditionValue, "fr-FR"));
return ordered.find((rule) => {
if (rule.isActive !== 1) return false;
const invoiceValue = rule.conditionField === "recipientName"
? invoice.recipientName
: invoice.ventilationComptable;
return normalized(invoiceValue) === normalized(rule.conditionValue);
});
}

View File

@@ -0,0 +1,22 @@
export const DEFAULT_IMPORT_LOG_PAGE_SIZE = 200;
export const MAX_IMPORT_LOG_PAGE_SIZE = 500;
export type ImportLogPaginationInput = {
limit?: number;
offset?: number;
};
/**
* Normalise les limites avant toute lecture de journal. Les imports historiques
* peuvent contenir plusieurs dizaines de milliers de lignes : l'interface ne
* doit jamais tenter de les transférer en une seule requête.
*/
export function normalizeImportLogPagination(input?: ImportLogPaginationInput) {
const requestedLimit = input?.limit ?? DEFAULT_IMPORT_LOG_PAGE_SIZE;
const requestedOffset = input?.offset ?? 0;
return {
limit: Math.min(Math.max(Math.trunc(requestedLimit), 1), MAX_IMPORT_LOG_PAGE_SIZE),
offset: Math.max(Math.trunc(requestedOffset), 0),
};
}

View File

@@ -0,0 +1,21 @@
export const IMPORT_LOG_RETENTION_OPTIONS = [3, 6, 12] as const;
export type ImportLogRetentionMonths = (typeof IMPORT_LOG_RETENTION_OPTIONS)[number];
export function isImportLogRetentionMonths(value: number): value is ImportLogRetentionMonths {
return IMPORT_LOG_RETENTION_OPTIONS.includes(value as ImportLogRetentionMonths);
}
/**
* Retourne la borne calendaire exclusive de la purge : les journaux strictement
* antérieurs à cette date peuvent être supprimés. Les factures et PDF ne sont
* jamais concernés par cette politique de rétention.
*/
export function getImportLogRetentionCutoff(
months: ImportLogRetentionMonths,
referenceDate = new Date(),
): Date {
const cutoff = new Date(referenceDate);
cutoff.setMonth(cutoff.getMonth() - months);
return cutoff;
}

View File

@@ -0,0 +1,17 @@
export type ImportResultSummary = {
importedCount: number;
duplicateCount: number;
errorCount: number;
};
/** Extrait le résultat final affiché par les services d'import. */
export function parseImportResultSummary(progress: string | null | undefined): ImportResultSummary | null {
if (!progress) return null;
const match = progress.match(/Termin[ée]\s*:\s*(\d+)\s+importée\(s\),\s*(\d+)\s+doublon\(s\)(?:,\s*(\d+)\s+erreur\(s\))?/i);
if (!match) return null;
return {
importedCount: Number(match[1]),
duplicateCount: Number(match[2]),
errorCount: Number(match[3] ?? 0),
};
}

108
shared/invoiceAnalytics.ts Normal file
View File

@@ -0,0 +1,108 @@
import { getInvoicePeriodDate, matchesInvoicePeriod } from "./invoicePeriod";
export type AnalyticsInvoice = {
invoiceDate?: Date | string | number | null;
createdAt?: Date | string | number | null;
isSubscription?: number | null;
status?: string | null;
supplierName?: string | null;
totalAmount?: number | string | null;
};
export type AnnualInvoiceSummary = {
year: number;
bapCount: number;
bapAmount: number;
subscriptionCount: number;
subscriptionAmount: number;
totalAmount: number;
};
export type SupplierBudgetSummary = {
supplierName: string;
subscriptionCount: number;
subscriptionAmount: number;
nonSubscriptionCount: number;
nonSubscriptionAmount: number;
totalAmount: number;
};
function getAmount(value: AnalyticsInvoice["totalAmount"]): number {
const amount = Number(value);
return Number.isFinite(amount) ? amount : 0;
}
/**
* Agrège les factures finalisées par année métier. « BAP » désigne ici les
* factures hors abonnement, éligibles au circuit BAP, validées ou non.
*/
export function buildAnnualInvoiceSummary(invoices: AnalyticsInvoice[]): AnnualInvoiceSummary[] {
const summaryByYear = new Map<number, AnnualInvoiceSummary>();
for (const invoice of invoices) {
if (invoice.status !== "completed") continue;
const date = getInvoicePeriodDate(invoice);
if (!date) continue;
const year = date.getFullYear();
const current = summaryByYear.get(year) ?? {
year,
bapCount: 0,
bapAmount: 0,
subscriptionCount: 0,
subscriptionAmount: 0,
totalAmount: 0,
};
const amount = getAmount(invoice.totalAmount);
if (invoice.isSubscription === 1) {
current.subscriptionCount += 1;
current.subscriptionAmount += amount;
} else {
current.bapCount += 1;
current.bapAmount += amount;
}
current.totalAmount += amount;
summaryByYear.set(year, current);
}
return Array.from(summaryByYear.values()).sort((a, b) => b.year - a.year);
}
/** Agrège le budget réellement facturé par fournisseur sur la période choisie. */
export function buildSupplierBudgetSummary(
invoices: AnalyticsInvoice[],
year: string,
month: string,
): SupplierBudgetSummary[] {
const summaryBySupplier = new Map<string, SupplierBudgetSummary>();
for (const invoice of invoices) {
if (invoice.status !== "completed" || !matchesInvoicePeriod(invoice, year, month)) continue;
const supplierName = invoice.supplierName?.trim() || "Fournisseur inconnu";
const current = summaryBySupplier.get(supplierName) ?? {
supplierName,
subscriptionCount: 0,
subscriptionAmount: 0,
nonSubscriptionCount: 0,
nonSubscriptionAmount: 0,
totalAmount: 0,
};
const amount = getAmount(invoice.totalAmount);
if (invoice.isSubscription === 1) {
current.subscriptionCount += 1;
current.subscriptionAmount += amount;
} else {
current.nonSubscriptionCount += 1;
current.nonSubscriptionAmount += amount;
}
current.totalAmount += amount;
summaryBySupplier.set(supplierName, current);
}
return Array.from(summaryBySupplier.values()).sort((a, b) =>
b.totalAmount - a.totalAmount || a.supplierName.localeCompare(b.supplierName, "fr"),
);
}

27
shared/invoicePeriod.ts Normal file
View File

@@ -0,0 +1,27 @@
/**
* Date métier utilisée pour les listes de factures : date de facture si elle
* est connue, sinon date de réception. Cette règle est partagée avec BAP.
*/
export function getInvoicePeriodDate(invoice: {
invoiceDate?: Date | string | number | null;
createdAt?: Date | string | number | null;
}): Date | null {
const value = invoice.invoiceDate ?? invoice.createdAt;
if (!value) return null;
const date = value instanceof Date ? value : new Date(value);
return Number.isNaN(date.getTime()) ? null : date;
}
/** Indique si une facture correspond au filtre Année/Mois sélectionné. */
export function matchesInvoicePeriod(
invoice: Parameters<typeof getInvoicePeriodDate>[0],
year: string,
month: string,
): boolean {
if (year === "all") return true;
const date = getInvoicePeriodDate(invoice);
if (!date || date.getFullYear() !== Number(year)) return false;
return month === "all" || date.getMonth() + 1 === Number(month);
}

59
shared/invoiceQuality.ts Normal file
View File

@@ -0,0 +1,59 @@
/** 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<string, unknown>,
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);
}
/**
* Prépare une extraction IA pour la persistance. Le score brut du modèle est
* volontairement remplacé par le calcul déterministe commun à tous les flux.
*/
export function withCalculatedInvoiceQuality<T extends object>(
invoice: T,
fieldsConfiguration: readonly QualityFieldConfig[],
): T & { qualityScore: number } {
return {
...invoice,
qualityScore: calculateInvoiceQualityScore(
invoice as Record<string, unknown>,
fieldsConfiguration,
),
};
}

View File

@@ -0,0 +1,13 @@
/**
* Autorise une nouvelle analyse uniquement lorsqu'une source existante du même
* utilisateur ne possède plus aucune facture, par exemple après une purge.
* Une source contenant déjà au moins une facture reste protégée contre les
* doublons, y compris entre comptes distincts.
*/
export function shouldReprocessEmptySource(input: {
sourceUserId: number;
requestingUserId: number;
invoiceCount: number;
}): boolean {
return input.sourceUserId === input.requestingUserId && input.invoiceCount === 0;
}

24
shared/webPortalUrl.ts Normal file
View File

@@ -0,0 +1,24 @@
/**
* Ajoute HTTPS aux adresses de portail saisies sous forme de domaine simple.
* Les protocoles explicites sont conservés afin que la validation puisse refuser
* proprement ceux qui ne sont pas HTTP(S).
*/
export function normalizeWebPortalUrl(value: string): string {
const trimmed = value.trim();
if (!trimmed) return "";
if (trimmed.startsWith("//")) return `https:${trimmed}`;
if (/^[a-z][a-z\d+.-]*:\/\//i.test(trimmed) || /^(?:mailto|tel|data):/i.test(trimmed)) {
return trimmed;
}
return `https://${trimmed}`;
}
/** Vérifie qu’une URL de portail est une adresse HTTP(S) avec un hôte. */
export function isValidWebPortalUrl(value: string): boolean {
try {
const url = new URL(value);
return (url.protocol === "https:" || url.protocol === "http:") && Boolean(url.hostname);
} catch {
return false;
}
}

334
todo.md
View File

@@ -1,5 +1,114 @@
# Invoice Analyzer - TODO
## Purge d’historique par rétention — 09/10/2026
- [x] Ajouter une purge administrateur pour les journaux antérieurs à 3 mois, 6 mois ou 1 an
- [x] Afficher le volume concerné et exiger une confirmation avant suppression
- [ ] Valider la purge ciblée en recette sans supprimer de factures ni de PDF
## Incident historique et rapport d’import — production — 09/10/2026
- [x] Tracer les requêtes et données : 101 886 journaux sont renvoyés sans limite à l’interface
- [x] Borner et paginer la récupération des journaux sans supprimer l’historique
- [ ] Valider l’historique et le rapport en recette puis en production
## Cohérence export / BAP — production — 09/10/2026
- [x] Tracer les états : Amazon et Inmac ont été exportées par le flux PDF générique le 30/09, sans BAP validé ni historique BAP
- [x] Bloquer tout nouvel export PDF hors abonnement avant validation BAP et rendre l’état historique explicite dans l’interface
- [ ] Valider la correction en recette puis promouvoir en production sans altérer les PDF ni l’historique existant
## Incident import LIFEN — production — 09/10/2026
- [x] Tracer l’import : PDF stocké, échec lors de l’invocation Référentiel IA (HTTP 400) avant la réponse du modèle
- [x] Identifier la cause : le contrat Référentiel IA exige `engineSlug`, absent de l’invocation ; ajouter le routage et le test de non-régression
- [x] Déployer le routage corrigé en recette et production, contrôler les conteneurs, les routes et les volumes PDF
- [x] Permettre le retraitement production d’une source en erreur sans facture, actuellement bloquée à tort comme doublon
- [x] Retraiter la facture LIFEN via le flux applicatif : 1 facture créée, score 100, statut terminé ; santé et volume PDF validés
## Incident Santino rapide — recette — 02/10/2026
- [x] Tracer la réponse de Santino rapide : génération tronquée pendant `extractedText`, sans fermeture JSON
- [x] Confirmer la limite de sortie : SANTINO Rapide interrompt à 512 tokens (`finishReason: length`)
- [x] Réduire la sortie IA au JSON métier et conserver le texte PDF localement
- [x] Déployer et valider une extraction réelle par SANTINO Rapide en recette
## Réimport après suppression — recette — 02/10/2026
- [x] Retirer le blocage de réimport des factures supprimées dans tous les flux
- [x] Valider qu’une facture supprimée peut être réimportée sans être déclarée doublon
## Incident affichage après import — recette — 02/10/2026
- [x] Contrôler la facture importée, les filtres actifs et l’identité de session : suppression suivie d’une liste noire résiduelle
- [x] Restaurer la facture demandée : 1 facture créée, score 100, statut terminé
- [ ] Déployer l’explication explicite des imports sans facture créée et vérifier la liste recette
## Correction score de qualité à l’import — recette — 02/10/2026
- [x] Auditer les flux d’extraction IA et aligner upload, e-mail, dossier et connecteur web sur le recalcul déterministe
- [x] Ajouter des tests d’exécution du calcul partagé utilisé par chaque flux d’import
- [x] Valider le score de la facture J6LNGDAL0021 et déployer en recette
- [x] Ajouter des tests d’exécution nommés pour chaque flux et rejouer l’import manuel en recette sans SQL de score
## Incident import manuel de PDF — recette — 02/10/2026
- [x] Tracer l’import du PDF soumis et identifier le point de blocage
- [x] Corriger le traitement des caractères de contrôle dans la réponse IA
- [x] Valider le retraitement complet du PDF déjà soumis en recette
- [x] Valider un upload manuel isolé avec une copie de test, puis supprimer toutes ses traces
## Régression signalée d’import manuel — recette — 02/10/2026
- [x] Tracer le dernier import après purge : les sources PDF et leur empreinte sont restées en base
- [x] Corriger la réanalyse des sources du même utilisateur restées vides après purge
- [x] Retirer l’entrée de liste noire résiduelle correspondant au PDF explicitement réimporté
- [x] Valider la réanalyse avec le PDF concerné en recette
## Correction suppression de facture — recette — 02/10/2026
- [x] Reproduire l’échec d’archivage dans `deletedInvoices` et identifier l’écart schéma/code
- [x] Corriger la persistance d’archivage et supprimer les détails SQL de l’interface
- [x] Tester le flux tRPC réel avec une facture de test, puis vérifier son archivage et son absence persistante
- [x] Déployer uniquement en recette sans toucher aux volumes PDF
## Choix du moteur IA — Paramètres — 30/09/2026
- [x] Analyser la configuration IA locale et le contrat du Référentiel IA
- [x] Ajouter le choix entre moteur local et moteur sélectionné dans le Référentiel IA
- [x] Valider la migration, les tests, TypeScript et le build en sandbox
- [x] Créer un jeton applicatif limité pour Démat. Facturation DSI, monter le secret Docker en lecture seule et configurer la passerelle Référentiel IA en recette
- [x] Vérifier l’autorisation et le moteur recommandé par le Référentiel IA sans envoyer de facture
- [x] Vérifier une invocation contrôlée via la passerelle sans utiliser de document métier
- [ ] Vérifier une extraction réelle via la passerelle et sa traçabilité dans le Référentiel IA
## Affichage du moteur Référentiel IA — recette — 02/10/2026
- [x] Afficher le moteur et le modèle recommandés dès la sélection du Référentiel IA
- [ ] Valider l’affichage, puis déployer en recette sans toucher à MySQL ni au volume PDF
## Correction connexion Référentiel IA — recette — 02/10/2026
- [x] Identifier le repli bloqué par une variable de jeton vide dans le conteneur
- [x] Corriger la lecture du secret Docker lorsqu’une variable vide est présente
- [x] Valider et déployer le correctif en recette sans toucher à MySQL ni au volume PDF
## Import manuel des connecteurs web — 30/09/2026
- [x] Analyser le flux serveur d’import web et définir le déclenchement manuel sécurisé
- [x] Ajouter l’API de déclenchement, le suivi de résultat et le bouton avec confirmation
- [ ] Diagnostiquer l’échec réel du connecteur SFR en recette, puis déployer uniquement après validation
## Incident création connecteur web — nouvelle tentative production — 30/09/2026
- [x] Reproduire l’insertion avec le même profil utilisateur sans exposer d’identifiant
- [ ] Journaliser un code technique non sensible en cas de nouvel échec SQL
- [ ] Valider et déployer l’instrumentation sans paramètres sensibles
## Correction création connecteur web en production — 30/09/2026
- [x] Diagnostiquer l’échec SQL d’insertion sans exposer les identifiants
- [x] Corriger le schéma ou la persistance du connecteur web
- [x] Valider de bout en bout la création tRPC, avec connecteur fictif supprimé après le test
## Correction validation connecteur web — 30/09/2026
- [x] Reproduire l’erreur `portalUrl` invalide et identifier l’écart formulaire/API
- [x] Corriger la normalisation et la validation de l’URL du portail
- [x] Valider les tests, puis déployer en recette et production sans toucher à MySQL ni aux volumes
## Diagnostic réception e-mail automatique — 24/09/2026
- [x] Reproduire le démarrage de la réception automatique et identifier le blocage interface → API → planificateur
- [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
@@ -703,7 +812,224 @@
- [x] Ajouter des tests de non-régression ciblés et vérifier build, types et tests
- [x] Normaliser les valeurs OAuth de loginMethod avant écriture en base
## Déploiement recette — audit de robustesse
- [ ] Pousser le checkpoint d’audit vers Gitea recette
- [ ] Reconstruire l’application sur le serveur de recette
- [ ] Vérifier le commit, les conteneurs et la disponibilité HTTP en recette
## Incident production — erreur HTTP 404
- [x] Reproduire la 404 et contrôler le domaine, Traefik et les conteneurs
- [x] Identifier et corriger la cause racine sans modifier les données
- [x] Vérifier le retour HTTP 200 et la santé des conteneurs
## Audit production — 674 factures affichées
- [x] Compter les factures par utilisateur, source et statut
- [x] Identifier les groupes de doublons selon plusieurs clés métier
- [x] Vérifier les références de stockage et les effets de la fusion précédente
- [x] Préparer une correction réversible sans suppression immédiate
- [x] Sauvegarder la base et le volume puis suspendre les imports email
- [x] Bloquer les réimports par empreinte PDF et fiabiliser le traitement IMAP
- [x] Déployer le correctif anti-réimport et migrer la base de production
- [x] Appliquer la correction confirmée et vérifier le comptage final
## Audit authentification IMAP Microsoft 365
- [x] Vérifier la génération du jeton Azure et le format XOAUTH2 envoyé à IMAP
- [x] Comparer les scopes, permissions et méthode d’authentification aux exigences Microsoft 365
- [x] Tester la configuration active de production sans exposer les secrets
- [x] Documenter la cause du refus IMAP et le correctif requis
## Migration import email vers ImapFlow
- [x] Remplacer la dépendance `imap` par `imapflow`
- [x] Réécrire la connexion OAuth2, la recherche UNSEEN et la lecture des messages
- [x] Conserver le traitement séquentiel, le verrou anti-concurrence et le marquage Seen après succès
- [x] Adapter le test de connexion IMAP et les messages d’erreur
- [x] Ajouter des tests de non-régression du flux ImapFlow
- [x] Vérifier TypeScript, tests, build et authentification OAuth2 réelle
## Déploiement ImapFlow — recette et production
- [x] Pousser la version ImapFlow vers le dépôt Gitea de recette
- [x] Réduire l’image runtime Docker pour fiabiliser le build sur le serveur de recette
- [x] Charger Vite uniquement en développement pour l’exclure de l’image runtime
- [x] Déployer et valider HTTP et conteneurs ImapFlow en recette (aucune source OAuth2 active à tester)
- [x] Corriger les règles Docker orphelines qui bloquaient MySQL en recette
- [x] Pousser la version validée vers le dépôt Gitea de production
- [x] Déployer et valider HTTP, conteneurs et OAuth2 ImapFlow en production
## Navigation depuis l’édition des factures BAP
- [x] Reproduire et identifier la perte du contexte de navigation depuis Factures BAP
- [x] Restaurer la page d’origine après fermeture de l’édition
- [x] Conserver les filtres et tris sélectionnés sur Factures BAP
- [x] Ajouter des tests de non-régression du retour contextuel
- [x] Vérifier TypeScript, tests, build et parcours en sandbox
- [x] Pousser le correctif vers Gitea recette et redéployer
- [x] Vérifier le parcours et la santé de la recette
## Automatisation de l’indicateur Abonnement
- [x] Analyser le modèle, le formulaire et l’exécution actuelle des automatismes
- [x] Ajouter l’option Abonnement : Oui, Non ou Ne pas modifier aux règles
- [x] Appliquer l’option Abonnement lors du traitement automatique des factures
- [x] Ajouter des tests de non-régression des règles d’abonnement
- [x] Valider les deux correctifs en sandbox
## Déploiement recette — navigation BAP et abonnement
- [x] Pousser les deux correctifs vers Gitea recette
- [x] Déployer, vérifier HTTP et valider le parcours de retour en recette
## Correctif recette — dialogue d’automatisme
- [x] Vérifier le commit et localiser l’emplacement absent de l’option Abonnement
- [x] Corriger le dialogue d’édition et le valider en recette
- [x] Arrêter le processus non applicatif autorisé qui bloque le build Docker
## Déploiement production — dialogue Abonnement
- [x] Pousser le correctif vers Gitea production
- [x] Construire et redémarrer uniquement le conteneur applicatif
- [x] Vérifier le commit, la santé, HTTP et le bundle de production
## Filtre par action des automatismes
- [x] Identifier les types d’action disponibles et leurs règles de détection
- [x] Ajouter un filtre d’action dans la liste des automatismes
- [x] Permettre d’isoler les règles Abonnement, Oui ou Non
- [x] Ajouter les tests et valider TypeScript, tests et build
## Déploiement — filtre par action des automatismes
- [x] Pousser le filtre par action vers Gitea recette
- [x] Déployer et vérifier le filtre par action en recette
- [x] Pousser la version validée vers Gitea production
- [x] Déployer et vérifier le filtre par action en production
## Seuil d’éligibilité BAP à 90 %
- [x] Identifier les validations BAP fondées sur le score de qualité
- [x] Abaisser le seuil de 100 % à 90 % pour les opérations BAP
- [x] Ajouter les tests de seuil et valider TypeScript, tests et build
## Déploiement — seuil BAP à 90 %
- [x] Pousser le correctif vers Gitea recette
- [x] Déployer et vérifier le seuil BAP à 90 % en recette
- [x] Pousser la version validée vers Gitea production
- [x] Déployer et vérifier le seuil BAP à 90 % en production
## Rétablissement du serveur de recette
- [x] Redémarrer le serveur de recette autorisé par l’utilisateur
- [x] Rétablir le conteneur applicatif et terminer le déploiement BAP à 90 %
## Factures abonnements et filtres de période
- [x] Analyser les listes Factures, Factures BAP et le menu Facturation
- [x] Ajouter les filtres Année et Mois à la page Factures
- [x] Créer la page Factures abonnements affichant uniquement les abonnements
- [x] Reprendre les recherches, filtres et actions de la page Factures
- [x] Ajouter l’entrée Factures abonnements après Factures BAP dans le menu
- [x] Ajouter les tests et valider TypeScript, build et affichage en sandbox
- [x] Déployer et vérifier le correctif en recette
- [x] Déployer et vérifier le correctif en production
## Synthèse annuelle du tableau de bord
- [x] Analyser les données et le tableau de bord existant
- [x] Calculer par année les volumes et montants BAP et abonnements
- [x] Afficher le nombre de factures et les montants par type, puis le total
- [x] Ajouter les tests et valider TypeScript, build et affichage en sandbox
## Budget réel par fournisseur
- [x] Définir l’agrégation des montants par fournisseur et statut Abonnement
- [x] Ajouter les filtres Année et Mois à l’écran Budget réel
- [x] Créer l’écran Budget réel à la fin du menu Facturation
- [x] Afficher par fournisseur les montants Abonnement, Hors abonnement et le total
- [x] Ajouter les tests et valider TypeScript, build et affichage en sandbox
## Améliorations Budget réel et tableau de bord
- [x] Confirmer le tri décroissant par montant total dans le budget
- [x] Ajouter un graphique annuel BAP versus abonnements au tableau de bord
- [x] Ajouter les tests et valider TypeScript, build et rendu sandbox
## Déploiement et visibilité production — budget et graphique
- [x] Pousser les améliorations vers Gitea recette
- [x] Déployer et vérifier les améliorations en recette
- [x] Pousser la version validée vers Gitea production
- [x] Déployer et vérifier les améliorations en production
- [x] Contrôler le dashboard et le portail de production : aucun changement requis, le catalogue et les statuts sont déjà à jour
## Contrôle de l’import automatique e-mail
- [x] Vérifier en production les paramètres et traces de l’import e-mail automatique : un compte reste activé dans la configuration, sans trace récente de déclenchement
## Désactivation de l’import automatique e-mail
- [x] Désactiver la planification e-mail pour tous les comptes en recette
- [x] Désactiver la planification e-mail pour tous les comptes en production
- [x] Redémarrer uniquement les conteneurs applicatifs pour vider les planifications en mémoire
- [x] Vérifier les paramètres, la santé et l’absence de redémarrage automatique sur les deux environnements
## Investigation des factures production du 1er septembre 2026
- [x] Identifier les deux factures créées et leur source d’import sans modifier les données
- [x] Corréler les factures avec l’historique, les fichiers source et les journaux applicatifs
- [x] Vérifier l’état de l’import e-mail au moment de leur création et déterminer la cause : le compte utilisateur 2 était activé, avec une cadence de 30 minutes
## Historique détaillé et déclenchement manuel des imports
- [x] Analyser les données d’import, les routes et la page Historique existantes
- [x] Ajouter une vue détaillée de l’historique avec source, compte, date, résultats et erreurs
- [x] Ajouter une action administrateur d’import e-mail manuel par compte configuré
- [x] Garantir que le déclenchement manuel ne modifie jamais la planification automatique
- [x] Ajouter les tests de non-régression et valider le parcours en sandbox
## Alignement des filtres Factures BAP
- [x] Ajouter les filtres Destinataires, Entités et Ventilations après le filtre Mois
- [x] Appliquer ces filtres à la liste BAP en préservant les filtres et le tri existants
- [x] Ajouter les tests et valider l’affichage de Factures BAP en sandbox
## Automatismes d’export et simplification des Paramètres
- [x] Ajouter les onglets Automatismes d’import et Automatismes d’export
- [x] Créer des règles de destination d’export par destinataire ou ventilation comptable
- [x] Prendre en charge les destinations dossier local, Teams et SharePoint sans perdre les réglages existants
- [x] Appliquer les règles de destination lors des exports de factures BAP
- [x] Déplacer les paramètres d’export dans Automatismes et renommer la page en Paramètres
- [x] Ajouter les tests et valider les nouveaux parcours en sandbox
## Répartition des paramètres Azure AD
- [x] Rétablir dans Paramètres l’URL SharePoint, Tenant ID, Client ID, secret et date d’expiration
- [x] Rétablir les tests Azure AD et upload SharePoint depuis Paramètres
- [x] Retirer les accès Microsoft Azure AD des automatismes d’export
- [x] Valider les deux emplacements en sandbox
## Présentation des automatismes
- [x] Coloriser les onglets Import et Export
- [x] Simplifier les libellés de l’onglet Export comme demandé
- [x] Vérifier le rendu en sandbox
## Déploiement des automatismes et paramètres
- [x] Synchroniser la version validée vers Gitea recette et déployer le service applicatif
- [x] Vérifier les routes, le conteneur et les migrations en recette
- [x] Synchroniser la même version vers Gitea production et déployer le service applicatif
- [x] Vérifier les routes, le conteneur, les migrations et le dashboard en production
## Filtre SANTINOVA des abonnements SFR
- [x] Identifier les deux factures SFR et leurs valeurs de destinataire, service et ventilation
- [x] Vérifier la logique du filtre SANTINOVA dans Factures abonnements : le filtre est correct, mais les deux factures ont été importées avant la création de la règle SFR
- [x] Appliquer la règle SFR aux deux factures existantes après confirmation utilisateur, puis vérifier le filtre Ventilation = SANTINOVA
## Incident 404 de production
- [x] Identifier l’origine du 404 : le label Traefik de production contient par erreur le domaine recette
- [x] Corriger uniquement le composant en cause sans toucher aux données ni aux volumes
- [x] Vérifier le retour HTTP 200 sur l’URL publique de production
## Incident factures et PDF de production
- [x] Vérifier les données factures, la connexion applicative à la base et les volumes PDF sans aucune suppression
- [x] Identifier les causes : le volume historique `/app/storage` contenant les PDF n’était plus monté, et les comptes Microsoft d’Olivier sont encore de rôle utilisateur sans facture propre
- [x] Corriger le composant PDF sans toucher aux factures ni aux volumes persistants
- [x] Vérifier l’ouverture d’un PDF en production
- [x] Promouvoir ADM.pareige et Olivier Pareige au rôle administrateur afin d’afficher les factures globales
## Prévention du démontage des PDF après reconstruction
- [x] Auditer les montages de stockage et la chaîne de reconstruction en recette et production
- [x] Ajouter un contrôle bloquant avant reconstruction et une vérification après démarrage du volume `/app/storage`
- [x] Vérifier les garde-fous sur les deux environnements sans modifier les données
## Correctif du démarrage cloud après garde-fou PDF
- [x] Adapter le garde-fou afin qu’il soit obligatoire uniquement avec `REQUIRE_STORAGE_VOLUME=true`
- [x] Conserver le contrôle strict du volume sur recette et production Santinova
- [x] Valider le démarrage du déploiement cloud sans volume local
## Portée des règles d’automatismes d’export
- [x] Vérifier l’application des règles lors des validations BAP : les règles actives sont bien résolues lors de la validation unitaire ou en masse
- [x] Vérifier l’application des règles lors des exports de factures d’abonnement : elles ne sont pas résolues par l’export générique actuel
- [x] Aligner l’export des factures d’abonnement sur les règles de destination après confirmation utilisateur
- [x] Résoudre une destination par facture d’abonnement sélectionnée selon les règles actives
- [x] Préserver les paramètres globaux comme repli lorsqu’aucune règle ne correspond
- [x] Ajouter les tests de priorité et de regroupement des exports d’abonnements
## Déploiement des règles d’export d’abonnements
- [x] Synchroniser la version validée et reconstruire le service applicatif de recette
- [x] Vérifier en recette les routes, le conteneur, le volume PDF et le flux de résolution par facture
- [x] Synchroniser la même version et reconstruire le service applicatif de production
- [x] Vérifier en production les routes, le conteneur, le volume PDF et le dashboard associé

12
verification_notes.md Normal file
View File

@@ -0,0 +1,12 @@
# Vérification visuelle — Historique des imports
- Le 2 septembre 2026, la page `/history` a été vérifiée en format bureau et mobile.
- Le cartouche administrateur de lecture e-mail ponctuelle est visible, explicite sur l’absence de planification créée et ne propose aucune activation automatique.
- Les filtres et indicateurs restent lisibles sur mobile.
- Le tableau est enveloppé dans un conteneur à défilement horizontal afin de préserver toutes les colonnes de traçabilité sur les écrans étroits.
## Vérification visuelle — Automatismes et Paramètres
- Le 2 septembre 2026, les routes `/automation-rules` et `/import-settings` ont été vérifiées en format bureau.
- Les onglets « Automatismes d’import » et « Automatismes d’export » sont visibles dans la page Automatismes.
- La navigation et le titre affichent désormais « Paramètres » ; seuls les onglets Paramètres d’import et Sauvegarde DB restent accessibles depuis cette page.