125 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
a1755e181c ci: trigger 2026-08-21 12:38:06 +02:00
Manus CI
5c8237d5c6 CI : workflow validate.yml, manifeste ci.required:true, garde VITEST, tests isolés 2026-08-21 12:38:06 +02:00
Manus Admin
a9cea0ecbb 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-21 12:38:06 +02:00
Manus
0612abf415 chore: versionner le manifeste de déploiement 2026-08-21 12:38:06 +02: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
e39dd38bec chore: suivi du déploiement recette 2026-08-18 07:40:54 +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
Manus
d8b2a8fe6f Checkpoint: Correction complémentaire : normalisation stricte des valeurs OAuth de loginMethod avant insertion DB, afin d’éviter les erreurs de troncature causées par les identifiants de plateforme externes. Ajout de tests associés et validation build complète. 2026-08-17 20:25:16 +00:00
Manus
54164b1b33 Checkpoint: Audit technique : suppression des composants, scripts et dépendances inutilisés ; chargement différé des pages ; génération de sauvegardes SQL robuste par lots ; centralisation des contrôles d’accès ; sécurisation des cookies Azure et imports web ; documentation de maintenance et 5 tests de non-régression ajoutés. 2026-08-17 20:21:58 +00:00
Manus
6b83361056 Checkpoint: mysqldump n'est pas disponible dans le container Node.js. Remplacement par un dump SQL généré directement via mysql2 : SHOW CREATE TABLE + SELECT * pour chaque table, avec échappement correct des valeurs. 2026-08-08 10:52:58 +00:00
Manus
d3426c1663 Checkpoint: Correction de l'erreur "non authentifié" sur /api/db-backup : utilisation de parseCookies(req.headers.cookie) au lieu de req.cookies (qui nécessitait cookie-parser non installé). 2026-08-08 10:41:01 +00:00
Manus
4ce2d2877a Checkpoint: Ajout onglet Sauvegarde DB dans ImportSettings : endpoint POST /api/db-backup (mysqldump), GET /api/db-backup/:filename, procédures tRPC backup.list et backup.delete, composant BackupSection avec liste des sauvegardes et téléchargement. 2026-08-08 10:04:57 +00:00
Manus
711ce6b83a Checkpoint: Ajout du système de connecteurs web : table webImportSources, CRUD tRPC, page WebImportSources.tsx, endpoint /api/web-import/push-invoice, script cron SFR (scripts/web-import/sfr-connector.mjs) 2026-07-30 13:53:07 +00:00
Manus
bcb307bde1 Checkpoint: Tous les boutons d'action (Excel, ZIP, Valider tout BAP, Importer, Supprimer, Dévalider BAP, Relancer) et la recherche sont maintenant sur une seule ligne dans le cartouche vert pastel, avec les filtres période et statut en dessous. 2026-07-30 12:48:20 +00:00
Manus
295ba29329 Checkpoint: Zone de filtres Factures BAP refactorisée en 3 lignes dans un cartouche vert émeraude pastel : ligne 1 (recherche), ligne 2 (filtres période année/mois), ligne 3 (boutons statut + tri aligné à droite). 2026-07-30 12:37:32 +00:00
Manus
ac0594f5db Checkpoint: Zone de filtres Factures refactorisée en 3 lignes dans un cartouche bleu pastel : ligne 1 (recherche + compact/détail), ligne 2 (destinataire, abonnement, entité, ventilation comptable), ligne 3 (boutons statut export + tri par date). 2026-07-30 12:24:15 +00:00
Manus
5d18dfa468 Checkpoint: Ajout de la table deletedInvoices (blacklist) : quand une facture est supprimée, son numéro est enregistré et l'import email/fichier ne la réimporte plus. 2026-07-30 09:48:00 +00:00
Manus
aebc607e8a Checkpoint: Ajout de deux nouveaux filtres sur la page Factures : Abonnement (OUI/NON basé sur isSubscription) et Entité (SANTINOVA/ITINOVA basé sur ventilationComptable === 'SANTINOVA'). Filtrage côté client, aucune modification backend nécessaire. 2026-07-30 08:15:56 +00:00
Manus
827c9ec41e Checkpoint: Ajout de Google Gemini (gemini-2.0-flash) comme 3ème moteur IA : schéma DB mis à jour (enum aiProvider + colonne geminiApiKey dans userSettings et importSettings), llm.ts mis à jour avec routing vers l'API Google AI Studio (compatible OpenAI), fenêtre de configuration IA mise à jour avec 3 cartes (Mistral / Gemini / Manus) et champ clé API Gemini. 2026-07-27 15:55:07 +00:00
Manus
46a61fca0e Checkpoint: Ventilations, automatismes, services et signatures sont maintenant globaux (sans filtre userId). Doublons supprimés en production. 2026-07-13 10:21:46 -04:00
Manus
1951b9b9d1 Checkpoint: Les admins voient désormais toutes les factures (tous userId) : getById, update, delete, validateBAP, devalidateBAP, validateBAPBulk, reprocessSelected, regenerateBapPdf, search, exportToExcel, exportToPdf, exportSFTP, bapHistory.delete/regenerate — tous corrigés pour les admins. 2026-07-13 08:13:49 -04:00
Manus
a994ec2e61 Checkpoint: Fix: colonne azureAdId agrandie à VARCHAR(128) pour accepter les identifiants Azure AD composés (objectId.tenantId). Migration appliquée en production. 2026-07-07 06:20:33 -04:00
Manus
8ee10602d3 Checkpoint: Ajout de la connexion OAuth2 Microsoft 365 (Azure AD) : route callback /api/auth/azure/callback, bouton Microsoft sur la page de login, variables d'environnement Azure AD configurées 2026-07-07 05:54:46 -04:00
Manus
d4c9bf635a Checkpoint: Fix: la procédure deleteAll des importLogs permet maintenant aux admins de supprimer tous les logs (pas seulement les leurs). Correction de deleteAllImportLogs dans db.ts pour accepter userId=null (suppression totale). 2026-06-17 06:20:40 -04:00
Manus
f7d3f6d5b3 Checkpoint: Ajout colonne "Source import" (Email / Dossier / Fichier) dans l'historique des imports : schéma DB migré, backend mis à jour (routers.ts, emailImportService.ts, folderImportService.ts), frontend History.tsx avec badges colorés dans le tableau et le dialog de détails. 2026-06-17 06:11:46 -04:00
Manus
b97efb5c57 Checkpoint: Fix: bouton de téléchargement PDF BAP pour factures validées sans PDF (NOVRH VF11521, EVOLUCARE ET098857). Ajout procédure regenerateBapPdf côté serveur + bouton orange RefreshCw côté frontend pour régénérer le PDF à la volée. Déployé en recette et production. 2026-06-17 03:41:18 -04:00
Manus
756f8ab392 fix: stabiliser validatedInvoiceIds avec useMemo pour bouton téléchargement BAP 2026-06-16 12:29:58 -04:00
Manus
1a09c98ef5 fix: exportStatus mis à jour à 'exported' après validation BAP (unitaire et en masse) 2026-06-16 12:18:40 -04:00
134 changed files with 31345 additions and 5535 deletions

View File

@@ -0,0 +1,45 @@
name: Validation applicative
on:
push:
branches: [main, master]
paths-ignore:
- "**.md"
- "docs/**"
pull_request:
branches: [main, master]
paths-ignore:
- "**.md"
- "docs/**"
workflow_dispatch:
jobs:
verify:
name: TypeScript, tests et build
# Label dédié : image locale avec Node 22, pnpm verrouillé et Bash déjà installés.
runs-on: ci-node22
timeout-minutes: 15
steps:
- name: Récupérer les sources
uses: actions/checkout@v4
- name: Calculer la clé de cache pnpm
id: pnpm-cache-key
shell: bash
run: |
echo "store=$(pnpm store path --silent)" >> "$GITHUB_OUTPUT"
echo "lock=$(sha256sum pnpm-lock.yaml | cut -d ' ' -f 1)" >> "$GITHUB_OUTPUT"
- name: Restaurer le store pnpm
uses: actions/cache@v4
with:
path: ${{ steps.pnpm-cache-key.outputs.store }}
key: pnpm-${{ runner.os }}-${{ steps.pnpm-cache-key.outputs.lock }}
restore-keys: |
pnpm-${{ runner.os }}-
- name: Installer les dépendances verrouillées
run: pnpm install --frozen-lockfile --prefer-offline
- name: Vérifier TypeScript, tests et build
run: pnpm verify

3
.gitignore vendored
View File

@@ -45,6 +45,8 @@ pids
*.seed
*.pid.lock
*.bak
backups/
storage/
# Coverage directory used by tools like istanbul
coverage/
@@ -105,3 +107,4 @@ temp/
*.db
*.sqlite
*.sqlite3
.project-config.json

View File

@@ -0,0 +1,9 @@
{
"query": "\nSELECT i.id, i.supplierName, i.invoiceNumber, i.bapValidated, i.exportStatus, \n bh.id as bapHistoryId, bh.pdfUrl, bh.validatedAt\nFROM invoices i \nLEFT JOIN bapHistory bh ON bh.invoiceId = i.id \nWHERE i.supplierName LIKE '%ALPES%' OR i.supplierName LIKE '%KOESIO%'\nORDER BY i.supplierName;\n",
"command": "mysql --batch --raw --column-names --default-character-set=utf8mb4 --host gateway02.us-east-1.prod.aws.tidbcloud.com --port 4000 --user 4CrrYuB5tme73Qo.bd4328423008 --database fo4DRyBgjsuiigFAgNLuWm --execute \nSELECT i.id, i.supplierName, i.invoiceNumber, i.bapValidated, i.exportStatus, \n bh.id as bapHistoryId, bh.pdfUrl, bh.validatedAt\nFROM invoices i \nLEFT JOIN bapHistory bh ON bh.invoiceId = i.id \nWHERE i.supplierName LIKE '%ALPES%' OR i.supplierName LIKE '%KOESIO%'\nORDER BY i.supplierName;\n",
"rows": [],
"messages": [],
"stdout": "",
"stderr": "",
"execution_time_ms": 287
}

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"]

16
app.json Normal file
View File

@@ -0,0 +1,16 @@
{
"id": "demat-facturation-dsi",
"name": "Démat. Facturation DSI",
"category": "SANTINOVA",
"urls": {
"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-dsi",
"giteaOwner": "manus-admin",
"ci": {
"required": true
}
}

View File

@@ -1,26 +1,44 @@
import { Toaster } from "@/components/ui/sonner";
import { TooltipProvider } from "@/components/ui/tooltip";
import NotFound from "@/pages/NotFound";
import { lazy, Suspense } from "react";
import { Route, Switch } from "wouter";
import ErrorBoundary from "./components/ErrorBoundary";
import { ThemeProvider } from "./contexts/ThemeContext";
import Home from "./pages/Home";
import Login from "./pages/Login";
import Dashboard from "./pages/Dashboard";
import Upload from "./pages/Upload";
import Invoices from "./pages/Invoices";
import InvoicesBAP from "./pages/InvoicesBAP";
import InvoiceDetail from "./pages/InvoiceDetail";
import Settings from "./pages/Settings";
import ImportSettings from "./pages/ImportSettings";
import History from "./pages/History";
import Users from "./pages/Users";
import ListsAdmin from "./pages/ListsAdmin";
import AutomationRules from "./pages/AutomationRules";
import BapHistory from "./pages/BapHistory";
import ImportReport from "./pages/ImportReport";
import LearningSettings from "./pages/LearningSettings";
import VentilationFreePro from "./pages/VentilationFreePro";
// Pages chargées à la demande : l'écran de connexion reste léger et chaque
// module métier ne télécharge son code qu'au moment où l'utilisateur l'ouvre.
const Home = lazy(() => import("./pages/Home"));
const Login = lazy(() => import("./pages/Login"));
const Dashboard = lazy(() => import("./pages/Dashboard"));
const Upload = lazy(() => import("./pages/Upload"));
const Invoices = lazy(() => import("./pages/Invoices"));
const InvoicesBAP = lazy(() => import("./pages/InvoicesBAP"));
const InvoiceDetail = lazy(() => import("./pages/InvoiceDetail"));
const Settings = lazy(() => import("./pages/Settings"));
const ImportSettings = lazy(() => import("./pages/ImportSettings"));
const History = lazy(() => import("./pages/History"));
const Users = lazy(() => import("./pages/Users"));
const ListsAdmin = lazy(() => import("./pages/ListsAdmin"));
const AutomationRules = lazy(() => import("./pages/AutomationRules"));
const BapHistory = lazy(() => import("./pages/BapHistory"));
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" />;
}
function Router() {
return (
@@ -29,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} />
@@ -42,6 +62,7 @@ function Router() {
<Route path="/import-report" component={ImportReport} />
<Route path="/learning-settings" component={LearningSettings} />
<Route path="/ventilation-freepro" component={VentilationFreePro} />
<Route path="/web-import-sources" component={WebImportSources} />
<Route path="/404" component={NotFound} />
<Route component={NotFound} />
</Switch>
@@ -54,7 +75,9 @@ function App() {
<ThemeProvider defaultTheme="light">
<TooltipProvider>
<Toaster />
<Router />
<Suspense fallback={<RouteFallback />}>
<Router />
</Suspense>
</TooltipProvider>
</ThemeProvider>
</ErrorBoundary>

View File

@@ -1,335 +0,0 @@
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import { ScrollArea } from "@/components/ui/scroll-area";
import { cn } from "@/lib/utils";
import { Loader2, Send, User, Sparkles } from "lucide-react";
import { useState, useEffect, useRef } from "react";
import { Streamdown } from "streamdown";
/**
* Message type matching server-side LLM Message interface
*/
export type Message = {
role: "system" | "user" | "assistant";
content: string;
};
export type AIChatBoxProps = {
/**
* Messages array to display in the chat.
* Should match the format used by invokeLLM on the server.
*/
messages: Message[];
/**
* Callback when user sends a message.
* Typically you'll call a tRPC mutation here to invoke the LLM.
*/
onSendMessage: (content: string) => void;
/**
* Whether the AI is currently generating a response
*/
isLoading?: boolean;
/**
* Placeholder text for the input field
*/
placeholder?: string;
/**
* Custom className for the container
*/
className?: string;
/**
* Height of the chat box (default: 600px)
*/
height?: string | number;
/**
* Empty state message to display when no messages
*/
emptyStateMessage?: string;
/**
* Suggested prompts to display in empty state
* Click to send directly
*/
suggestedPrompts?: string[];
};
/**
* A ready-to-use AI chat box component that integrates with the LLM system.
*
* Features:
* - Matches server-side Message interface for seamless integration
* - Markdown rendering with Streamdown
* - Auto-scrolls to latest message
* - Loading states
* - Uses global theme colors from index.css
*
* @example
* ```tsx
* const ChatPage = () => {
* const [messages, setMessages] = useState<Message[]>([
* { role: "system", content: "You are a helpful assistant." }
* ]);
*
* const chatMutation = trpc.ai.chat.useMutation({
* onSuccess: (response) => {
* // Assuming your tRPC endpoint returns the AI response as a string
* setMessages(prev => [...prev, {
* role: "assistant",
* content: response
* }]);
* },
* onError: (error) => {
* console.error("Chat error:", error);
* // Optionally show error message to user
* }
* });
*
* const handleSend = (content: string) => {
* const newMessages = [...messages, { role: "user", content }];
* setMessages(newMessages);
* chatMutation.mutate({ messages: newMessages });
* };
*
* return (
* <AIChatBox
* messages={messages}
* onSendMessage={handleSend}
* isLoading={chatMutation.isPending}
* suggestedPrompts={[
* "Explain quantum computing",
* "Write a hello world in Python"
* ]}
* />
* );
* };
* ```
*/
export function AIChatBox({
messages,
onSendMessage,
isLoading = false,
placeholder = "Type your message...",
className,
height = "600px",
emptyStateMessage = "Start a conversation with AI",
suggestedPrompts,
}: AIChatBoxProps) {
const [input, setInput] = useState("");
const scrollAreaRef = useRef<HTMLDivElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const inputAreaRef = useRef<HTMLFormElement>(null);
const textareaRef = useRef<HTMLTextAreaElement>(null);
// Filter out system messages
const displayMessages = messages.filter((msg) => msg.role !== "system");
// Calculate min-height for last assistant message to push user message to top
const [minHeightForLastMessage, setMinHeightForLastMessage] = useState(0);
useEffect(() => {
if (containerRef.current && inputAreaRef.current) {
const containerHeight = containerRef.current.offsetHeight;
const inputHeight = inputAreaRef.current.offsetHeight;
const scrollAreaHeight = containerHeight - inputHeight;
// Reserve space for:
// - padding (p-4 = 32px top+bottom)
// - user message: 40px (item height) + 16px (margin-top from space-y-4) = 56px
// Note: margin-bottom is not counted because it naturally pushes the assistant message down
const userMessageReservedHeight = 56;
const calculatedHeight = scrollAreaHeight - 32 - userMessageReservedHeight;
setMinHeightForLastMessage(Math.max(0, calculatedHeight));
}
}, []);
// Scroll to bottom helper function with smooth animation
const scrollToBottom = () => {
const viewport = scrollAreaRef.current?.querySelector(
'[data-radix-scroll-area-viewport]'
) as HTMLDivElement;
if (viewport) {
requestAnimationFrame(() => {
viewport.scrollTo({
top: viewport.scrollHeight,
behavior: 'smooth'
});
});
}
};
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
const trimmedInput = input.trim();
if (!trimmedInput || isLoading) return;
onSendMessage(trimmedInput);
setInput("");
// Scroll immediately after sending
scrollToBottom();
// Keep focus on input
textareaRef.current?.focus();
};
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
handleSubmit(e);
}
};
return (
<div
ref={containerRef}
className={cn(
"flex flex-col bg-card text-card-foreground rounded-lg border shadow-sm",
className
)}
style={{ height }}
>
{/* Messages Area */}
<div ref={scrollAreaRef} className="flex-1 overflow-hidden">
{displayMessages.length === 0 ? (
<div className="flex h-full flex-col p-4">
<div className="flex flex-1 flex-col items-center justify-center gap-6 text-muted-foreground">
<div className="flex flex-col items-center gap-3">
<Sparkles className="size-12 opacity-20" />
<p className="text-sm">{emptyStateMessage}</p>
</div>
{suggestedPrompts && suggestedPrompts.length > 0 && (
<div className="flex max-w-2xl flex-wrap justify-center gap-2">
{suggestedPrompts.map((prompt, index) => (
<button
key={index}
onClick={() => onSendMessage(prompt)}
disabled={isLoading}
className="rounded-lg border border-border bg-card px-4 py-2 text-sm transition-colors hover:bg-accent disabled:cursor-not-allowed disabled:opacity-50"
>
{prompt}
</button>
))}
</div>
)}
</div>
</div>
) : (
<ScrollArea className="h-full">
<div className="flex flex-col space-y-4 p-4">
{displayMessages.map((message, index) => {
// Apply min-height to last message only if NOT loading (when loading, the loading indicator gets it)
const isLastMessage = index === displayMessages.length - 1;
const shouldApplyMinHeight =
isLastMessage && !isLoading && minHeightForLastMessage > 0;
return (
<div
key={index}
className={cn(
"flex gap-3",
message.role === "user"
? "justify-end items-start"
: "justify-start items-start"
)}
style={
shouldApplyMinHeight
? { minHeight: `${minHeightForLastMessage}px` }
: undefined
}
>
{message.role === "assistant" && (
<div className="size-8 shrink-0 mt-1 rounded-full bg-primary/10 flex items-center justify-center">
<Sparkles className="size-4 text-primary" />
</div>
)}
<div
className={cn(
"max-w-[80%] rounded-lg px-4 py-2.5",
message.role === "user"
? "bg-primary text-primary-foreground"
: "bg-muted text-foreground"
)}
>
{message.role === "assistant" ? (
<div className="prose prose-sm dark:prose-invert max-w-none">
<Streamdown>{message.content}</Streamdown>
</div>
) : (
<p className="whitespace-pre-wrap text-sm">
{message.content}
</p>
)}
</div>
{message.role === "user" && (
<div className="size-8 shrink-0 mt-1 rounded-full bg-secondary flex items-center justify-center">
<User className="size-4 text-secondary-foreground" />
</div>
)}
</div>
);
})}
{isLoading && (
<div
className="flex items-start gap-3"
style={
minHeightForLastMessage > 0
? { minHeight: `${minHeightForLastMessage}px` }
: undefined
}
>
<div className="size-8 shrink-0 mt-1 rounded-full bg-primary/10 flex items-center justify-center">
<Sparkles className="size-4 text-primary" />
</div>
<div className="rounded-lg bg-muted px-4 py-2.5">
<Loader2 className="size-4 animate-spin text-muted-foreground" />
</div>
</div>
)}
</div>
</ScrollArea>
)}
</div>
{/* Input Area */}
<form
ref={inputAreaRef}
onSubmit={handleSubmit}
className="flex gap-2 p-4 border-t bg-background/50 items-end"
>
<Textarea
ref={textareaRef}
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={placeholder}
className="flex-1 max-h-32 resize-none min-h-9"
rows={1}
/>
<Button
type="submit"
size="icon"
disabled={!input.trim() || isLoading}
className="shrink-0 h-[38px] w-[38px]"
>
{isLoading ? (
<Loader2 className="size-4 animate-spin" />
) : (
<Send className="size-4" />
)}
</Button>
</form>
</div>
);
}

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

@@ -23,13 +23,11 @@ import {
useSidebar,
} from "@/components/ui/sidebar";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
import { getLoginUrl } from "@/const";
import { useIsMobile } from "@/hooks/useMobile";
import { LayoutDashboard, LogOut, PanelLeft, Users, FileText, Upload, History, Settings, Download, List, Zap, ChevronDown, Receipt, Cog, ClipboardList, CheckSquare, Brain, BarChart2, BarChart3 } from "lucide-react";
import { LayoutDashboard, LogOut, PanelLeft, Users, FileText, Upload, History, Settings, Download, List, Zap, ChevronDown, Receipt, Cog, ClipboardList, CheckSquare, Brain, BarChart2, BarChart3, Globe } from "lucide-react";
import { CSSProperties, useEffect, useRef, useState } from "react";
import { useLocation } from "wouter";
import { DashboardLayoutSkeleton } from './DashboardLayoutSkeleton';
import { Button } from "./ui/button";
type MenuItem = {
icon: any;
@@ -55,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" },
],
},
{
@@ -71,10 +71,11 @@ 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" },
{ icon: Globe, label: "Connecteurs web", path: "/web-import-sources" },
{ icon: Users, label: "Utilisateurs", path: "/users", adminOnly: true },
],
},

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

@@ -1,155 +0,0 @@
/**
* GOOGLE MAPS FRONTEND INTEGRATION - ESSENTIAL GUIDE
*
* USAGE FROM PARENT COMPONENT:
* ======
*
* const mapRef = useRef<google.maps.Map | null>(null);
*
* <MapView
* initialCenter={{ lat: 40.7128, lng: -74.0060 }}
* initialZoom={15}
* onMapReady={(map) => {
* mapRef.current = map; // Store to control map from parent anytime, google map itself is in charge of the re-rendering, not react state.
* </MapView>
*
* ======
* Available Libraries and Core Features:
* -------------------------------
* 📍 MARKER (from `marker` library)
* - Attaches to map using { map, position }
* new google.maps.marker.AdvancedMarkerElement({
* map,
* position: { lat: 37.7749, lng: -122.4194 },
* title: "San Francisco",
* });
*
* -------------------------------
* 🏢 PLACES (from `places` library)
* - Does not attach directly to map; use data with your map manually.
* const place = new google.maps.places.Place({ id: PLACE_ID });
* await place.fetchFields({ fields: ["displayName", "location"] });
* map.setCenter(place.location);
* new google.maps.marker.AdvancedMarkerElement({ map, position: place.location });
*
* -------------------------------
* 🧭 GEOCODER (from `geocoding` library)
* - Standalone service; manually apply results to map.
* const geocoder = new google.maps.Geocoder();
* geocoder.geocode({ address: "New York" }, (results, status) => {
* if (status === "OK" && results[0]) {
* map.setCenter(results[0].geometry.location);
* new google.maps.marker.AdvancedMarkerElement({
* map,
* position: results[0].geometry.location,
* });
* }
* });
*
* -------------------------------
* 📐 GEOMETRY (from `geometry` library)
* - Pure utility functions; not attached to map.
* const dist = google.maps.geometry.spherical.computeDistanceBetween(p1, p2);
*
* -------------------------------
* 🛣️ ROUTES (from `routes` library)
* - Combines DirectionsService (standalone) + DirectionsRenderer (map-attached)
* const directionsService = new google.maps.DirectionsService();
* const directionsRenderer = new google.maps.DirectionsRenderer({ map });
* directionsService.route(
* { origin, destination, travelMode: "DRIVING" },
* (res, status) => status === "OK" && directionsRenderer.setDirections(res)
* );
*
* -------------------------------
* 🌦️ MAP LAYERS (attach directly to map)
* - new google.maps.TrafficLayer().setMap(map);
* - new google.maps.TransitLayer().setMap(map);
* - new google.maps.BicyclingLayer().setMap(map);
*
* -------------------------------
* ✅ SUMMARY
* - “map-attached” → AdvancedMarkerElement, DirectionsRenderer, Layers.
* - “standalone” → Geocoder, DirectionsService, DistanceMatrixService, ElevationService.
* - “data-only” → Place, Geometry utilities.
*/
/// <reference types="@types/google.maps" />
import { useEffect, useRef } from "react";
import { usePersistFn } from "@/hooks/usePersistFn";
import { cn } from "@/lib/utils";
declare global {
interface Window {
google?: typeof google;
}
}
const API_KEY = import.meta.env.VITE_FRONTEND_FORGE_API_KEY;
const FORGE_BASE_URL =
import.meta.env.VITE_FRONTEND_FORGE_API_URL ||
"https://forge.butterfly-effect.dev";
const MAPS_PROXY_URL = `${FORGE_BASE_URL}/v1/maps/proxy`;
function loadMapScript() {
return new Promise(resolve => {
const script = document.createElement("script");
script.src = `${MAPS_PROXY_URL}/maps/api/js?key=${API_KEY}&v=weekly&libraries=marker,places,geocoding,geometry`;
script.async = true;
script.crossOrigin = "anonymous";
script.onload = () => {
resolve(null);
script.remove(); // Clean up immediately
};
script.onerror = () => {
console.error("Failed to load Google Maps script");
};
document.head.appendChild(script);
});
}
interface MapViewProps {
className?: string;
initialCenter?: google.maps.LatLngLiteral;
initialZoom?: number;
onMapReady?: (map: google.maps.Map) => void;
}
export function MapView({
className,
initialCenter = { lat: 37.7749, lng: -122.4194 },
initialZoom = 12,
onMapReady,
}: MapViewProps) {
const mapContainer = useRef<HTMLDivElement>(null);
const map = useRef<google.maps.Map | null>(null);
const init = usePersistFn(async () => {
await loadMapScript();
if (!mapContainer.current) {
console.error("Map container not found");
return;
}
map.current = new window.google.maps.Map(mapContainer.current, {
zoom: initialZoom,
center: initialCenter,
mapTypeControl: true,
fullscreenControl: true,
zoomControl: true,
streetViewControl: true,
mapId: "DEMO_MAP_ID",
});
if (onMapReady) {
onMapReady(map.current);
}
});
useEffect(() => {
init();
}, [init]);
return (
<div ref={mapContainer} className={cn("w-full h-[500px]", className)} />
);
}

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>

File diff suppressed because it is too large Load Diff

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,17 +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,
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";
@@ -52,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" },
@@ -68,421 +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">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">
<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>
{/* 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

@@ -2,7 +2,6 @@ import { useEffect } from "react";
import { useAuth } from "@/_core/hooks/useAuth";
import { Loader2, FileText } from "lucide-react";
import { useLocation } from "wouter";
import { Button } from "@/components/ui/button";
export default function Home() {
const [, setLocation] = useLocation();

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,15 +8,154 @@ 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,
Inbox, CheckCircle2, Monitor, FolderOutput, Wifi, AlertTriangle, Calendar,
ArrowDownToLine, Share2
ArrowDownToLine, Share2, DatabaseBackup, HardDrive, CheckCircle, Clock
} from "lucide-react";
import DashboardLayout from "@/components/DashboardLayout";
// ============= COMPOSANT SAUVEGARDE DB =============
function BackupSection() {
const [isGenerating, setIsGenerating] = useState(false);
const { data: backupList, refetch: refetchList } = trpc.backup.list.useQuery();
const deleteBackupMutation = trpc.backup.delete.useMutation({
onSuccess: () => { refetchList(); toast.success("Sauvegarde supprimée"); },
onError: (e) => toast.error("Erreur : " + e.message),
});
const handleGenerateBackup = async () => {
setIsGenerating(true);
try {
const response = await fetch("/api/db-backup", { method: "POST", credentials: "include" });
if (!response.ok) {
const err = await response.json().catch(() => ({ error: "Erreur inconnue" }));
toast.error("Erreur : " + (err.error || response.statusText));
return;
}
// Déclencher le téléchargement
const blob = await response.blob();
const contentDisposition = response.headers.get("Content-Disposition") || "";
const match = contentDisposition.match(/filename\*?=(?:UTF-8'')?["']?([^"';\n]+)/i);
const fileName = match ? decodeURIComponent(match[1]) : `backup-${new Date().toISOString().slice(0,10)}.sql`;
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = fileName;
a.click();
URL.revokeObjectURL(url);
toast.success("Sauvegarde générée et téléchargée");
refetchList();
} catch (e: any) {
toast.error("Erreur : " + e.message);
} finally {
setIsGenerating(false);
}
};
const formatSize = (bytes: number) => {
if (bytes < 1024) return bytes + " o";
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + " Ko";
return (bytes / (1024 * 1024)).toFixed(1) + " Mo";
};
return (
<Card className="border-2 hover:border-primary/50 transition-colors">
<CardHeader className="bg-gradient-to-r from-slate-50 to-gray-50 dark:from-slate-950/20 dark:to-gray-950/20 border-b">
<div className="flex items-center gap-3">
<div className="p-2 bg-slate-600 rounded-lg">
<DatabaseBackup className="w-6 h-6 text-white" />
</div>
<div>
<CardTitle className="text-xl">Sauvegarde de la base de données</CardTitle>
<CardDescription className="mt-1">
Générer un dump SQL de la base de données et l'enregistrer localement dans le dossier <code className="bg-muted px-1 rounded text-xs">backups/</code>
</CardDescription>
</div>
</div>
</CardHeader>
<CardContent className="pt-6 space-y-6">
{/* Bouton générer */}
<div className="flex items-center gap-4">
<Button
onClick={handleGenerateBackup}
disabled={isGenerating}
className="gap-2 bg-slate-700 hover:bg-slate-800 text-white"
size="lg"
>
{isGenerating ? (
<><Loader2 className="h-5 w-5 animate-spin" />Génération en cours...</>
) : (
<><HardDrive className="h-5 w-5" />Générer une sauvegarde</>
)}
</Button>
<p className="text-sm text-muted-foreground">
Le dump SQL sera généré, enregistré dans <code className="bg-muted px-1 rounded text-xs">backups/</code> et téléchargé automatiquement.
</p>
</div>
{/* Liste des sauvegardes existantes */}
{backupList && backupList.length > 0 && (
<div className="space-y-2">
<div className="flex items-center gap-2 text-sm font-medium text-muted-foreground">
<Clock className="h-4 w-4" />
Sauvegardes enregistrées ({backupList.length})
</div>
<div className="border rounded-lg divide-y">
{backupList.map((backup) => (
<div key={backup.name} className="flex items-center justify-between px-4 py-3 hover:bg-muted/30">
<div className="flex items-center gap-3">
<CheckCircle className="h-4 w-4 text-green-500 shrink-0" />
<div>
<p className="text-sm font-medium font-mono">{backup.name}</p>
<p className="text-xs text-muted-foreground">
{new Date(backup.createdAt).toLocaleString("fr-FR")} — {formatSize(backup.size)}
</p>
</div>
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
className="gap-1 text-xs"
onClick={() => { window.open(`/api/db-backup/${encodeURIComponent(backup.name)}`, "_blank"); }}
>
<Download className="h-3 w-3" />
Télécharger
</Button>
<Button
variant="outline"
size="sm"
className="gap-1 text-xs text-red-600 hover:text-red-700 hover:bg-red-50"
onClick={() => deleteBackupMutation.mutate({ name: backup.name })}
disabled={deleteBackupMutation.isPending}
>
Supprimer
</Button>
</div>
</div>
))}
</div>
</div>
)}
{backupList && backupList.length === 0 && (
<div className="text-center py-8 text-muted-foreground text-sm">
<HardDrive className="h-8 w-8 mx-auto mb-2 opacity-30" />
Aucune sauvegarde enregistrée
</div>
)}
</CardContent>
</Card>
);
}
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,
@@ -36,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);
@@ -120,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";
@@ -197,30 +365,32 @@ 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-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 value="backup" className="flex items-center gap-2 text-base">
<DatabaseBackup className="w-4 h-4" />
Sauvegarde DB
</TabsTrigger>
</TabsList>
{/* ===== 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">
@@ -398,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>
@@ -429,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>
@@ -461,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">
@@ -602,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}
@@ -618,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>
@@ -955,6 +1200,10 @@ export default function ImportSettings() {
</Button>
</div>
</TabsContent>
{/* ===== ONGLET SAUVEGARDE ===== */}
<TabsContent value="backup" className="space-y-6">
<BackupSection />
</TabsContent>
</Tabs>
</div>
</DashboardLayout>

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,6 +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>(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);
@@ -191,11 +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 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) {
@@ -211,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();
@@ -221,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;
@@ -236,26 +265,40 @@ export default function Invoices() {
if ((inv as any).recipientName !== recipientFilter) return false;
}
}
// Filter by subscription
if (subscriptionFilter !== "all") {
const isSub = (inv as any).isSubscription === 1 || (inv as any).isSubscription === true;
if (subscriptionFilter === "yes" && !isSub) return false;
if (subscriptionFilter === "no" && isSub) return false;
}
// Filter by entity (SANTINOVA = serviceConcerne === 'DSI SANTINOVA', ITINOVA = autre)
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;
}
// Filter by ventilation comptable
if (ventilationFilter !== "all") {
if (ventilationFilter === "__empty__") {
if ((inv as any).ventilationComptable) return false;
} else {
if ((inv as any).ventilationComptable !== ventilationFilter) return false;
}
}
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) {
@@ -316,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) => {
@@ -342,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
@@ -359,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})
@@ -392,109 +441,171 @@ export default function Invoices() {
</div>
</div>
<Card>
<CardContent className="pt-6">
{/* Search + Recipient Filter */}
<div className="mb-4 flex gap-3 flex-wrap">
<div className="relative flex-1 min-w-[200px]">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 w-4 h-4" />
<Input
placeholder="Rechercher par fournisseur, destinataire ou numéro..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-10"
/>
</div>
<div className="flex items-center gap-2 min-w-[220px]">
<Filter className="w-4 h-4 text-gray-400 shrink-0" />
<Select value={recipientFilter} onValueChange={setRecipientFilter}>
<SelectTrigger className="w-full">
<SelectValue placeholder="Filtrer par destinataire" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Tous les destinataires</SelectItem>
<SelectItem value="__empty__">Sans destinataire</SelectItem>
{uniqueRecipients.map((r) => (
<SelectItem key={r} value={r}>{r}</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
{/* Mode compact/détail + Tri */}
<div className="flex gap-2 mb-4 flex-wrap items-center justify-between">
<div className="flex gap-2">
<Button
variant={compactMode ? "default" : "outline"}
size="sm"
onClick={() => setCompactMode(true)}
title="Mode compact"
>
<LayoutList className="w-4 h-4 mr-1" /> Compact
</Button>
<Button
variant={!compactMode ? "default" : "outline"}
size="sm"
onClick={() => setCompactMode(false)}
title="Mode détail"
>
<LayoutGrid className="w-4 h-4 mr-1" /> Détail
</Button>
</div>
<div className="flex gap-2 items-center">
<span className="text-sm text-gray-500">Trier par :</span>
<Button
variant={sortField === "createdAt" ? "default" : "outline"}
size="sm"
onClick={() => handleSort("createdAt")}
>
Date réception <SortIcon field="createdAt" />
</Button>
<Button
variant={sortField === "invoiceDate" ? "default" : "outline"}
size="sm"
onClick={() => handleSort("invoiceDate")}
>
Date facture <SortIcon field="invoiceDate" />
</Button>
</div>
</div>
{/* ===== CARTOUCHE FILTRES ===== */}
<div className="rounded-xl border border-blue-100 bg-blue-50/60 px-4 py-3 mb-4 shadow-sm">
{/* Status Filters */}
<div className="flex gap-2 mb-4">
{/* Ligne 1 : Recherche + affichage compact/détail */}
<div className="flex gap-3 items-center flex-wrap mb-3">
<div className="relative flex-1 min-w-[220px]">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-blue-400 w-4 h-4" />
<Input
placeholder="Rechercher par fournisseur, destinataire ou numéro..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-10 bg-white border-blue-200 focus:border-blue-400"
/>
</div>
<div className="flex gap-1 shrink-0">
<Button
variant={statusFilter === "all" ? "default" : "outline"}
onClick={() => setStatusFilter("all")}
variant={compactMode ? "default" : "outline"}
size="sm"
onClick={() => setCompactMode(true)}
title="Mode compact"
className={compactMode ? "bg-blue-600 hover:bg-blue-700" : "bg-white border-blue-200 text-blue-700 hover:bg-blue-50"}
>
Tous ({statusCounts.all})
<LayoutList className="w-4 h-4 mr-1" /> Compact
</Button>
<Button
variant={statusFilter === "exported" ? "default" : "outline"}
onClick={() => setStatusFilter("exported")}
variant={!compactMode ? "default" : "outline"}
size="sm"
className={statusFilter === "exported" ? "bg-green-600 hover:bg-green-700" : ""}
onClick={() => setCompactMode(false)}
title="Mode détail"
className={!compactMode ? "bg-blue-600 hover:bg-blue-700" : "bg-white border-blue-200 text-blue-700 hover:bg-blue-50"}
>
Exportés ({statusCounts.exported})
</Button>
<Button
variant={statusFilter === "not_exported" ? "default" : "outline"}
onClick={() => setStatusFilter("not_exported")}
size="sm"
className={statusFilter === "not_exported" ? "bg-blue-600 hover:bg-blue-700" : ""}
>
Non exportés ({statusCounts.not_exported})
</Button>
<Button
variant={statusFilter === "export_error" ? "default" : "outline"}
onClick={() => setStatusFilter("export_error")}
size="sm"
className={statusFilter === "export_error" ? "bg-red-600 hover:bg-red-700" : ""}
>
Erreurs ({statusCounts.export_error})
<LayoutGrid className="w-4 h-4 mr-1" /> Détail
</Button>
</div>
</div>
{/* 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" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Tous destinataires</SelectItem>
<SelectItem value="__empty__">Sans destinataire</SelectItem>
{uniqueRecipients.map((r) => (
<SelectItem key={r} value={r}>{r}</SelectItem>
))}
</SelectContent>
</Select>
<Select value={isSubscriptionPage ? "yes" : subscriptionFilter} onValueChange={setSubscriptionFilter} disabled={isSubscriptionPage}>
<SelectTrigger className="w-[170px] bg-white border-blue-200 text-sm">
<SelectValue placeholder="Abonnement" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Abonnement : tous</SelectItem>
<SelectItem value="yes">Abonnement : OUI</SelectItem>
<SelectItem value="no">Abonnement : NON</SelectItem>
</SelectContent>
</Select>
<Select value={entityFilter} onValueChange={setEntityFilter}>
<SelectTrigger className="w-[150px] bg-white border-blue-200 text-sm">
<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] bg-white border-blue-200 text-sm">
<SelectValue placeholder="Ventilation" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Toutes ventilations</SelectItem>
<SelectItem value="__empty__">Sans ventilation</SelectItem>
{uniqueVentilations.map((v) => (
<SelectItem key={v} value={v}>{v}</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Ligne 3 : Boutons statut export + tri */}
<div className="flex gap-2 items-center flex-wrap">
<Button
variant={statusFilter === "all" ? "default" : "outline"}
onClick={() => setStatusFilter("all")}
size="sm"
className={statusFilter === "all" ? "bg-blue-600 hover:bg-blue-700" : "bg-white border-blue-200 text-blue-700 hover:bg-blue-50"}
>
Tous ({statusCounts.all})
</Button>
<Button
variant={statusFilter === "exported" ? "default" : "outline"}
onClick={() => setStatusFilter("exported")}
size="sm"
className={statusFilter === "exported" ? "bg-green-600 hover:bg-green-700 text-white" : "bg-white border-green-200 text-green-700 hover:bg-green-50"}
>
Exportés ({statusCounts.exported})
</Button>
<Button
variant={statusFilter === "not_exported" ? "default" : "outline"}
onClick={() => setStatusFilter("not_exported")}
size="sm"
className={statusFilter === "not_exported" ? "bg-indigo-600 hover:bg-indigo-700 text-white" : "bg-white border-indigo-200 text-indigo-700 hover:bg-indigo-50"}
>
Non exportés ({statusCounts.not_exported})
</Button>
<Button
variant={statusFilter === "export_error" ? "default" : "outline"}
onClick={() => setStatusFilter("export_error")}
size="sm"
className={statusFilter === "export_error" ? "bg-red-600 hover:bg-red-700 text-white" : "bg-white border-red-200 text-red-700 hover:bg-red-50"}
>
Erreurs ({statusCounts.export_error})
</Button>
<div className="ml-auto flex gap-1 items-center">
<span className="text-xs text-blue-500 font-medium mr-1">Trier :</span>
<Button
variant={sortField === "createdAt" ? "default" : "outline"}
size="sm"
onClick={() => handleSort("createdAt")}
className={sortField === "createdAt" ? "bg-blue-600 hover:bg-blue-700" : "bg-white border-blue-200 text-blue-700 hover:bg-blue-50"}
>
Réception <SortIcon field="createdAt" />
</Button>
<Button
variant={sortField === "invoiceDate" ? "default" : "outline"}
size="sm"
onClick={() => handleSort("invoiceDate")}
className={sortField === "invoiceDate" ? "bg-blue-600 hover:bg-blue-700" : "bg-white border-blue-200 text-blue-700 hover:bg-blue-50"}
>
Facture <SortIcon field="invoiceDate" />
</Button>
</div>
</div>
</div>
{/* ===== FIN CARTOUCHE ===== */}
<Card>
<CardContent className="pt-4">
{/* Table */}
{isLoading ? (

View File

@@ -1,4 +1,4 @@
import React, { useState } from "react";
import React, { useState, useMemo } from "react";
import DashboardLayout from "@/components/DashboardLayout";
import { Card, CardContent } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
@@ -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,8 +176,21 @@ 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)
const validatedInvoiceIds = (invoices || []).filter(inv => inv.bapValidated === 1).map(inv => inv.id);
// Stabilisé avec useMemo pour éviter les re-renders infinis (anti-pattern tRPC)
const validatedInvoiceIds = useMemo(
() => (invoices || []).filter(inv => inv.bapValidated === 1).map(inv => inv.id),
// eslint-disable-next-line react-hooks/exhaustive-deps
[invoices?.map(i => i.id).join(',')]
);
const { data: persistedBapPdfUrls } = trpc.invoices.getBapPdfUrls.useQuery(
{ invoiceIds: validatedInvoiceIds },
{ enabled: validatedInvoiceIds.length > 0 }
@@ -284,6 +319,21 @@ export default function InvoicesBAP() {
},
});
const regenerateBapPdfMutation = trpc.invoices.regenerateBapPdf.useMutation({
onSuccess: (data, variables) => {
if (data.pdfUrl) {
setBapPdfUrls(prev => ({ ...prev, [variables.invoiceId]: data.pdfUrl as string }));
toast.success('PDF BAP régénéré avec succès !');
downloadBapPdf(data.pdfUrl);
}
utils.invoices.list.invalidate();
utils.invoices.getBapPdfUrls.invalidate();
},
onError: (error) => {
toast.error(error.message || 'Erreur lors de la régénération du PDF BAP');
},
});
const devalidateBAPMutation = trpc.invoices.devalidateBAP.useMutation({
onSuccess: (data) => {
toast.success(`${data.processed} facture(s) dévalidée(s) BAP avec succès`);
@@ -360,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 &&
@@ -405,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;
});
@@ -477,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>;
@@ -492,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
@@ -515,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");
@@ -567,17 +639,19 @@ export default function InvoicesBAP() {
<h1 className="text-3xl font-bold">Factures BAP</h1>
<p className="text-gray-500 mt-1">Factures non-abonnement (Abonnement = NON)</p>
</div>
{/* Barre d'actions : 2 lignes */}
<div className="space-y-2">
{/* Ligne 1 : Excel, ZIP, Valider tout en BAP, Importer */}
<div className="flex flex-wrap gap-2">
{/* ===== CARTOUCHE FILTRES + ACTIONS BAP ===== */}
<div className="rounded-xl border border-emerald-100 bg-emerald-50/60 px-4 py-3 mb-4 shadow-sm">
{/* Ligne 1 : Boutons d'action + Recherche */}
<div className="flex gap-2 items-center flex-wrap mb-3">
<Button
onClick={handleExportExcel}
disabled={selectedIds.length === 0 || exportExcelMutation.isPending}
variant="outline"
className="border-green-600 text-green-600 hover:bg-green-50"
size="sm"
className="bg-white border-green-300 text-green-700 hover:bg-green-50"
>
<FileSpreadsheet className="w-4 h-4 mr-2" />
<FileSpreadsheet className="w-4 h-4 mr-1" />
Excel ({selectedIds.length})
</Button>
<Button
@@ -611,30 +685,29 @@ export default function InvoicesBAP() {
}}
disabled={selectedIds.length === 0 || isZipDownloading}
variant="outline"
className="border-indigo-500 text-indigo-600 hover:bg-indigo-50"
size="sm"
className="bg-white border-indigo-300 text-indigo-700 hover:bg-indigo-50"
>
<FolderDown className={`w-4 h-4 mr-2 ${isZipDownloading ? 'animate-bounce' : ''}`} />
<FolderDown className={`w-4 h-4 mr-1 ${isZipDownloading ? 'animate-bounce' : ''}`} />
{isZipDownloading ? 'ZIP...' : `ZIP (${selectedIds.length})`}
</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();
}
}}
disabled={validateBAPBulkMutation.isPending}
size="sm"
className="bg-green-700 hover:bg-green-800 text-white"
>
<ShieldCheck className="w-4 h-4 mr-2" />
{validateBAPBulkMutation.isPending ? "Validation en cours..." : "Valider tout en BAP"}
<ShieldCheck className="w-4 h-4 mr-1" />
{validateBAPBulkMutation.isPending ? "Validation..." : "Valider tout BAP"}
</Button>
<Button onClick={() => setLocation("/upload")}>
<FileText className="w-4 h-4 mr-2" />
<Button onClick={() => setLocation("/upload")} size="sm" className="bg-white border-emerald-300 text-emerald-700 hover:bg-emerald-50" variant="outline">
<FileText className="w-4 h-4 mr-1" />
Importer
</Button>
</div>
{/* Ligne 2 : Supprimer, Dévalider BAP, Relancer */}
<div className="flex flex-wrap gap-2">
<Button
onClick={async () => {
if (confirm(`Voulez-vous vraiment supprimer ${selectedIds.length} facture(s) ?`)) {
@@ -652,8 +725,9 @@ export default function InvoicesBAP() {
}}
disabled={selectedIds.length === 0}
variant="destructive"
size="sm"
>
<Trash2 className="w-4 h-4 mr-2" />
<Trash2 className="w-4 h-4 mr-1" />
Supprimer ({selectedIds.length})
</Button>
<Button
@@ -669,9 +743,10 @@ export default function InvoicesBAP() {
}}
disabled={selectedIds.length === 0 || devalidateBAPMutation.isPending}
variant="outline"
className="border-orange-500 text-orange-600 hover:bg-orange-50"
size="sm"
className="bg-white border-orange-300 text-orange-700 hover:bg-orange-50"
>
<ShieldCheck className="w-4 h-4 mr-2 rotate-180" />
<ShieldCheck className="w-4 h-4 mr-1 rotate-180" />
{devalidateBAPMutation.isPending ? "Dévalidation..." : `Dévalider BAP (${selectedIds.length})`}
</Button>
<Button
@@ -686,154 +761,187 @@ export default function InvoicesBAP() {
}}
disabled={selectedIds.length === 0 || reprocessMutation.isPending}
variant="outline"
className="border-purple-500 text-purple-600 hover:bg-purple-50"
size="sm"
className="bg-white border-purple-300 text-purple-700 hover:bg-purple-50"
>
<RefreshCw className={`w-4 h-4 mr-2 ${reprocessMutation.isPending ? 'animate-spin' : ''}`} />
<RefreshCw className={`w-4 h-4 mr-1 ${reprocessMutation.isPending ? 'animate-spin' : ''}`} />
{reprocessMutation.isPending ? `Retraitement...` : `Relancer (${selectedIds.length})`}
</Button>
{/* Recherche à droite */}
<div className="relative flex-1 min-w-[200px] ml-auto">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-emerald-400 w-4 h-4" />
<Input
placeholder="Rechercher par fournisseur ou numéro..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-10 bg-white border-emerald-200 focus:border-emerald-400 h-8 text-sm"
/>
</div>
</div>
</div>
<Card>
<CardContent className="pt-6">
{/* Search */}
<div className="mb-4">
<div className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 w-4 h-4" />
<Input
placeholder="Rechercher par fournisseur ou numéro..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-10"
/>
</div>
{/* Ligne 2 : Filtres période (année + mois) */}
<div className="flex flex-wrap items-center gap-2 mb-3">
<div className="flex items-center gap-1.5 text-sm font-medium text-emerald-600">
<CalendarDays className="w-4 h-4" />
Période :
</div>
{/* Period Filter + Status Filters */}
<div className="flex flex-wrap items-center gap-2 mb-3 p-3 bg-slate-50 dark:bg-slate-900/50 rounded-lg border border-slate-200 dark:border-slate-700">
<div className="flex items-center gap-1.5 text-sm font-medium text-slate-600 dark:text-slate-400">
<CalendarDays className="w-4 h-4" />
Période :
</div>
{/* Year selector */}
<Select value={selectedYear} onValueChange={(v) => { setSelectedYear(v); if (v === "all") setSelectedMonth("all"); }}>
<SelectTrigger className="w-28 h-8 text-sm">
<SelectValue placeholder="Année" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Toute année</SelectItem>
{Array.from({ length: 5 }, (_, i) => currentYear - i).map(y => (
<SelectItem key={y} value={String(y)}>{y}</SelectItem>
))}
</SelectContent>
</Select>
{/* Month selector — disabled when year = all */}
<Select
value={selectedMonth}
onValueChange={setSelectedMonth}
disabled={selectedYear === "all"}
<Select value={selectedYear} onValueChange={(v) => { setSelectedYear(v); if (v === "all") setSelectedMonth("all"); }}>
<SelectTrigger className="w-28 h-8 text-sm bg-white border-emerald-200">
<SelectValue placeholder="Année" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Toute année</SelectItem>
{Array.from({ length: 5 }, (_, i) => currentYear - i).map(y => (
<SelectItem key={y} value={String(y)}>{y}</SelectItem>
))}
</SelectContent>
</Select>
<Select
value={selectedMonth}
onValueChange={setSelectedMonth}
disabled={selectedYear === "all"}
>
<SelectTrigger className="w-36 h-8 text-sm bg-white border-emerald-200">
<SelectValue placeholder="Mois" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Tous les mois</SelectItem>
{[
["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"]
].map(([v, label]) => (
<SelectItem key={v} value={v}>{label}</SelectItem>
))}
</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"); }}
className="text-xs text-emerald-600 hover:text-emerald-800 underline"
>
<SelectTrigger className="w-36 h-8 text-sm">
<SelectValue placeholder="Mois" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Tous les mois</SelectItem>
{[
["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"]
].map(([v, label]) => (
<SelectItem key={v} value={v}>{label}</SelectItem>
))}
</SelectContent>
</Select>
{(selectedYear !== "all" || selectedMonth !== "all") && (
<button
onClick={() => { setSelectedYear(String(currentYear)); setSelectedMonth("all"); }}
className="text-xs text-slate-500 hover:text-slate-700 underline"
>
Réinitialiser
</button>
)}
</div>
Réinitialiser
</button>
)}
</div>
{/* Tri */}
<div className="flex gap-2 items-center mb-3">
<span className="text-sm text-gray-500">Trier par :</span>
{/* Ligne 3 : Boutons statut + tri */}
<div className="flex gap-2 items-center flex-wrap">
<Button
variant={statusFilter === "all" ? "default" : "outline"}
onClick={() => setStatusFilter("all")}
size="sm"
className={statusFilter === "all" ? "bg-emerald-600 hover:bg-emerald-700" : "bg-white border-emerald-200 text-emerald-700 hover:bg-emerald-50"}
>
Tous ({statusCounts.all})
</Button>
<Button
variant={statusFilter === "exported" ? "default" : "outline"}
onClick={() => setStatusFilter("exported")}
size="sm"
className={statusFilter === "exported" ? "bg-green-600 hover:bg-green-700 text-white" : "bg-white border-green-200 text-green-700 hover:bg-green-50"}
>
Exportés ({statusCounts.exported})
</Button>
<Button
variant={statusFilter === "not_exported" ? "default" : "outline"}
onClick={() => setStatusFilter("not_exported")}
size="sm"
className={statusFilter === "not_exported" ? "bg-indigo-600 hover:bg-indigo-700 text-white" : "bg-white border-indigo-200 text-indigo-700 hover:bg-indigo-50"}
>
Non exportés ({statusCounts.not_exported})
</Button>
<Button
variant={statusFilter === "export_error" ? "default" : "outline"}
onClick={() => setStatusFilter("export_error")}
size="sm"
className={statusFilter === "export_error" ? "bg-red-600 hover:bg-red-700 text-white" : "bg-white border-red-200 text-red-700 hover:bg-red-50"}
>
Erreurs ({statusCounts.export_error})
</Button>
<Button
variant={statusFilter === "bap_validated" ? "default" : "outline"}
onClick={() => setStatusFilter("bap_validated")}
size="sm"
className={statusFilter === "bap_validated" ? "bg-emerald-600 hover:bg-emerald-700 text-white" : "bg-white border-emerald-200 text-emerald-700 hover:bg-emerald-50"}
>
✅ Validées BAP ({statusCounts.bap_validated})
</Button>
<Button
variant={statusFilter === "bap_pending" ? "default" : "outline"}
onClick={() => setStatusFilter("bap_pending")}
size="sm"
className={statusFilter === "bap_pending" ? "bg-orange-600 hover:bg-orange-700 text-white" : "bg-white border-orange-200 text-orange-700 hover:bg-orange-50"}
>
⏳ En attente BAP ({statusCounts.bap_pending})
</Button>
<Button
variant={statusFilter === "to_complete" ? "default" : "outline"}
onClick={() => setStatusFilter("to_complete")}
size="sm"
className={statusFilter === "to_complete" ? "bg-amber-600 hover:bg-amber-700 text-white" : "bg-white border-amber-200 text-amber-700 hover:bg-amber-50"}
>
✏️ À compléter ({statusCounts.to_complete})
</Button>
<div className="ml-auto flex gap-1 items-center">
<span className="text-xs text-emerald-500 font-medium mr-1">Trier :</span>
<Button
variant={sortField === "createdAt" ? "default" : "outline"}
size="sm"
onClick={() => handleSort("createdAt")}
className={sortField === "createdAt" ? "bg-emerald-600 hover:bg-emerald-700" : "bg-white border-emerald-200 text-emerald-700 hover:bg-emerald-50"}
>
Date réception <SortIcon field="createdAt" />
Réception <SortIcon field="createdAt" />
</Button>
<Button
variant={sortField === "invoiceDate" ? "default" : "outline"}
size="sm"
onClick={() => handleSort("invoiceDate")}
className={sortField === "invoiceDate" ? "bg-emerald-600 hover:bg-emerald-700" : "bg-white border-emerald-200 text-emerald-700 hover:bg-emerald-50"}
>
Date facture <SortIcon field="invoiceDate" />
Facture <SortIcon field="invoiceDate" />
</Button>
</div>
</div>
</div>
{/* ===== FIN CARTOUCHE BAP ===== */}
{/* Status Filters */}
<div className="flex flex-wrap gap-2 mb-4">
<Button
variant={statusFilter === "all" ? "default" : "outline"}
onClick={() => setStatusFilter("all")}
size="sm"
>
Tous ({statusCounts.all})
</Button>
<Button
variant={statusFilter === "exported" ? "default" : "outline"}
onClick={() => setStatusFilter("exported")}
size="sm"
className={statusFilter === "exported" ? "bg-green-600 hover:bg-green-700" : ""}
>
Exportés ({statusCounts.exported})
</Button>
<Button
variant={statusFilter === "not_exported" ? "default" : "outline"}
onClick={() => setStatusFilter("not_exported")}
size="sm"
className={statusFilter === "not_exported" ? "bg-blue-600 hover:bg-blue-700" : ""}
>
Non exportés ({statusCounts.not_exported})
</Button>
<Button
variant={statusFilter === "export_error" ? "default" : "outline"}
onClick={() => setStatusFilter("export_error")}
size="sm"
className={statusFilter === "export_error" ? "bg-red-600 hover:bg-red-700" : ""}
>
Erreurs ({statusCounts.export_error})
</Button>
<Button
variant={statusFilter === "bap_validated" ? "default" : "outline"}
onClick={() => setStatusFilter("bap_validated")}
size="sm"
className={statusFilter === "bap_validated" ? "bg-emerald-600 hover:bg-emerald-700" : ""}
>
✅ Validées BAP ({statusCounts.bap_validated})
</Button>
<Button
variant={statusFilter === "bap_pending" ? "default" : "outline"}
onClick={() => setStatusFilter("bap_pending")}
size="sm"
className={statusFilter === "bap_pending" ? "bg-orange-600 hover:bg-orange-700" : ""}
>
⏳ En attente BAP ({statusCounts.bap_pending})
</Button>
<Button
variant={statusFilter === "to_complete" ? "default" : "outline"}
onClick={() => setStatusFilter("to_complete")}
size="sm"
className={statusFilter === "to_complete" ? "bg-amber-600 hover:bg-amber-700" : "border-amber-400 text-amber-700 hover:bg-amber-50"}
>
✏️ À compléter ({statusCounts.to_complete})
</Button>
</div>
<Card>
<CardContent className="pt-4">
{/* Table */}
{isLoading ? (
@@ -904,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"}
@@ -936,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
@@ -954,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"
>
@@ -995,11 +1103,14 @@ export default function InvoicesBAP() {
<Button
size="sm"
variant="outline"
className="h-8 px-2 text-gray-400 border-gray-200 cursor-not-allowed"
title="PDF non disponible"
disabled
className="h-8 px-2 text-orange-500 border-orange-300 hover:bg-orange-50"
title="PDF non disponible — cliquer pour régénérer"
disabled={regenerateBapPdfMutation.isPending}
onClick={() => regenerateBapPdfMutation.mutate({ invoiceId: invoice.id })}
>
<Download className="h-4 w-4" />
{regenerateBapPdfMutation.isPending && regenerateBapPdfMutation.variables?.invoiceId === invoice.id
? <RefreshCw className="h-4 w-4 animate-spin" />
: <RefreshCw className="h-4 w-4" />}
</Button>
)}
</div>

View File

@@ -1,4 +1,4 @@
import { useState } from "react";
import { useState, useEffect } from "react";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
@@ -10,9 +10,33 @@ import { toast } from "sonner";
const ITINOVA_LOGO = "https://d2xsxph8kpxj0f.cloudfront.net/310519663070627318/fo4DRyBgjsuiigFAgNLuWm/itinova-logo_2a2ba00a.jpg";
const SANTINOVA_LOGO = "https://d2xsxph8kpxj0f.cloudfront.net/310519663070627318/fo4DRyBgjsuiigFAgNLuWm/santinova-logo_5ae0d248.webp";
// Logo Microsoft SVG officiel
function MicrosoftLogo() {
return (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 21 21" width="18" height="18">
<rect x="1" y="1" width="9" height="9" fill="#f25022"/>
<rect x="11" y="1" width="9" height="9" fill="#7fba00"/>
<rect x="1" y="11" width="9" height="9" fill="#00a4ef"/>
<rect x="11" y="11" width="9" height="9" fill="#ffb900"/>
</svg>
);
}
export default function Login() {
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [azureLoading, setAzureLoading] = useState(false);
// Afficher les erreurs transmises via query param (ex: depuis le callback Azure)
useEffect(() => {
const params = new URLSearchParams(window.location.search);
const error = params.get("error");
if (error) {
toast.error(decodeURIComponent(error));
// Nettoyer l'URL
window.history.replaceState({}, "", "/login");
}
}, []);
const loginMutation = trpc.auth.loginLocal.useMutation({
onSuccess: () => {
@@ -24,11 +48,36 @@ export default function Login() {
},
});
const azureLoginQuery = trpc.auth.getAzureLoginUrl.useQuery(undefined, {
enabled: false,
retry: false,
});
const azureAvailableQuery = trpc.auth.isAzureAdAvailable.useQuery();
const handleLocalLogin = (e: React.FormEvent) => {
e.preventDefault();
loginMutation.mutate({ email: username, password });
};
const handleMicrosoftLogin = async () => {
setAzureLoading(true);
try {
const result = await azureLoginQuery.refetch();
if (result.data?.url) {
window.location.href = result.data.url;
} else {
toast.error("Impossible d'obtenir l'URL de connexion Microsoft");
setAzureLoading(false);
}
} catch {
toast.error("Erreur lors de la connexion Microsoft");
setAzureLoading(false);
}
};
const azureAvailable = azureAvailableQuery.data?.available ?? false;
return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-slate-50 to-blue-50 p-4">
<div className="w-full max-w-sm flex flex-col items-center gap-6">
@@ -50,6 +99,35 @@ export default function Login() {
<p className="text-sm text-slate-500 mt-1">Connectez-vous pour accéder à l'application</p>
</div>
{/* Bouton Microsoft 365 */}
{azureAvailable && (
<>
<Button
type="button"
variant="outline"
className="w-full flex items-center gap-3 border-slate-300 bg-white hover:bg-slate-50 text-slate-700 font-medium mb-4"
onClick={handleMicrosoftLogin}
disabled={azureLoading}
>
{azureLoading ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<MicrosoftLogo />
)}
Se connecter avec Microsoft 365
</Button>
<div className="relative mb-4">
<div className="absolute inset-0 flex items-center">
<span className="w-full border-t border-slate-200" />
</div>
<div className="relative flex justify-center text-xs uppercase">
<span className="bg-white px-2 text-slate-400">ou</span>
</div>
</div>
</>
)}
<form onSubmit={handleLocalLogin} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="username">Identifiant</Label>

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,12 +677,16 @@ export default function Settings() {
const [sftpAutoExport, setSftpAutoExport] = useState(false);
const [sftpRecipientFilter, setSftpRecipientFilter] = useState("");
const [llmLogsRetentionMonths, setLlmLogsRetentionMonths] = useState(3);
const [aiProvider, setAiProvider] = useState<"mistral" | "manus">("mistral");
const [aiProvider, setAiProvider] = useState<"mistral" | "manus" | "gemini" | "referentiel">("mistral");
const [mistralApiKey, setMistralApiKey] = useState("");
const [manusForgeApiKey, setManusForgeApiKey] = useState("");
const [manusForgeApiUrl, setManusForgeApiUrl] = useState("");
const [geminiApiKey, setGeminiApiKey] = useState("");
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) {
@@ -706,6 +710,7 @@ export default function Settings() {
setMistralApiKey((settings as any).mistralApiKey || "");
setManusForgeApiKey((settings as any).manusForgeApiKey || "");
setManusForgeApiUrl((settings as any).manusForgeApiUrl || "");
setGeminiApiKey((settings as any).geminiApiKey || "");
}
}, [settings]);
@@ -719,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) {
@@ -754,6 +783,7 @@ export default function Settings() {
mistralApiKey,
manusForgeApiKey,
manusForgeApiUrl,
geminiApiKey,
});
};
@@ -853,7 +883,7 @@ export default function Settings() {
<CardContent className="space-y-6 pt-6">
{/* Provider selector */}
<div className="grid grid-cols-2 gap-4">
<div className="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-4 gap-4">
<button
type="button"
onClick={() => setAiProvider("mistral")}
@@ -880,6 +910,36 @@ export default function Settings() {
)}
</button>
{/* Google Gemini */}
<button
type="button"
onClick={() => setAiProvider("gemini")}
className={`relative flex flex-col items-center gap-3 p-5 rounded-xl border-2 transition-all cursor-pointer ${
aiProvider === "gemini"
? "border-green-500 bg-green-50 dark:bg-green-950/20 shadow-md"
: "border-muted hover:border-green-300 bg-background"
}`}
>
{aiProvider === "gemini" && (
<div className="absolute top-2 right-2">
<CheckCircle className="w-5 h-5 text-green-500" />
</div>
)}
<div className="p-3 bg-green-100 dark:bg-green-900/30 rounded-xl">
<svg className="w-8 h-8" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2z" fill="#4285F4"/>
<path d="M12 6l1.5 4.5H18l-3.75 2.75L15.75 18 12 15.25 8.25 18l1.5-4.75L6 10.5h4.5L12 6z" fill="white"/>
</svg>
</div>
<div className="text-center">
<p className="font-bold text-base">Google Gemini</p>
<p className="text-xs text-muted-foreground mt-1">Gemini 2.0 Flash<br/>Gratuit (1500 req/j)</p>
</div>
{aiProvider === "gemini" && (
<Badge className="bg-green-500 text-white text-xs">Actif</Badge>
)}
</button>
<button
type="button"
onClick={() => setAiProvider("manus")}
@@ -905,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 */}
@@ -938,6 +1024,46 @@ export default function Settings() {
</div>
)}
{/* Gemini config */}
{aiProvider === "gemini" && (
<div className="space-y-4 p-4 bg-green-50 dark:bg-green-950/10 rounded-xl border border-green-200 dark:border-green-800">
<div className="flex items-center justify-between">
<p className="text-sm font-semibold text-green-700 dark:text-green-400 uppercase tracking-wide">Configuration Google Gemini</p>
<Badge className="bg-green-100 text-green-700 border border-green-300 text-xs font-normal">Gratuit — 1 500 req/jour</Badge>
</div>
<div className="space-y-2">
<Label htmlFor="geminiApiKey" className="font-medium">Clé API Google AI Studio</Label>
<div className="relative">
<Input
id="geminiApiKey"
type={showGeminiKey ? "text" : "password"}
value={geminiApiKey}
onChange={(e) => setGeminiApiKey(e.target.value)}
placeholder="Votre clé API Google (ex: AIzaSy...)"
className="h-11 pr-10"
/>
<button
type="button"
onClick={() => setShowGeminiKey(!showGeminiKey)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
>
{showGeminiKey ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
</button>
</div>
<p className="text-xs text-muted-foreground flex items-center gap-1">
<AlertCircle className="w-3 h-3" />
Obtenez votre clé gratuite sur{" "}
<a href="https://aistudio.google.com/apikey" target="_blank" rel="noopener noreferrer" className="text-green-600 hover:underline">aistudio.google.com/apikey</a>.
Laissez vide pour utiliser la variable d'environnement GEMINI_API_KEY du serveur.
</p>
</div>
<div className="p-3 bg-green-100 dark:bg-green-900/20 rounded-lg border border-green-200 dark:border-green-800">
<p className="text-xs text-green-800 dark:text-green-300 font-medium">Modèle utilisé : <span className="font-bold">gemini-2.0-flash</span></p>
<p className="text-xs text-green-700 dark:text-green-400 mt-1">Analyse directe des PDF, extraction structurée JSON, compatible avec l'interface OpenAI.</p>
</div>
</div>
)}
{/* Manus config */}
{aiProvider === "manus" && (
<div className="space-y-4 p-4 bg-blue-50 dark:bg-blue-950/10 rounded-xl border border-blue-200 dark:border-blue-800">
@@ -981,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>
@@ -993,26 +1164,33 @@ export default function Settings() {
<div>
<CardTitle className="text-xl">Configuration du modèle AI</CardTitle>
<CardDescription className="mt-1">
Paramètres du modèle d'extraction Mistral AI
Paramètres avancés du modèle d'extraction IA
</CardDescription>
</div>
</div>
</CardHeader>
<CardContent className="space-y-6 pt-6">
<div className="space-y-3">
<Label htmlFor="llmModel" className="text-base font-semibold">Modèle Mistral</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

@@ -0,0 +1,465 @@
import { useState } from "react";
import { trpc } from "@/lib/trpc";
import DashboardLayout from "@/components/DashboardLayout";
import { Button } from "@/components/ui/button";
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 { Badge } from "@/components/ui/badge";
import { isValidWebPortalUrl, normalizeWebPortalUrl } from "@shared/webPortalUrl";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from "@/components/ui/dialog";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { Globe, Plus, Pencil, Trash2, Key, CheckCircle, XCircle, Clock, RefreshCw, Play } from "lucide-react";
import { toast } from "sonner";
const CONNECTOR_TYPES = [
{ value: "sfr", label: "SFR Pro", url: "https://www.sfr.fr/mon-espace-client/" },
{ value: "orange", label: "Orange Pro", url: "https://espaceclient.orange.fr/" },
{ value: "bouygues", label: "Bouygues Telecom", url: "https://www.bouyguestelecom.fr/mon-compte/" },
{ value: "free", label: "Free Pro", url: "https://pro.free.fr/" },
{ value: "starlink", label: "Starlink", url: "https://www.starlink.com/account/" },
{ value: "custom", label: "Autre (personnalisé)", url: "" },
];
const FREQUENCY_LABELS: Record<string, string> = {
manual: "Manuel",
daily: "Quotidien",
weekly: "Hebdomadaire",
monthly: "Mensuel",
};
type Source = {
id: number;
name: string;
connectorType: string;
portalUrl: string;
loginEmail: string;
loginPassword: string;
frequency: "manual" | "daily" | "weekly" | "monthly";
autoEnabled: number;
lastSuccessAt: Date | null;
lastStatus: string | null;
lastImportCount: number | null;
apiToken: string;
createdAt: Date;
updatedAt: Date;
};
type FormData = {
name: string;
connectorType: string;
portalUrl: string;
loginEmail: string;
loginPassword: string;
frequency: "manual" | "daily" | "weekly" | "monthly";
autoEnabled: number;
};
const emptyForm: FormData = {
name: "",
connectorType: "sfr",
portalUrl: "https://www.sfr.fr/mon-espace-client/",
loginEmail: "",
loginPassword: "",
frequency: "monthly",
autoEnabled: 0,
};
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);
const { data: sources = [], refetch } = trpc.webImportSources.list.useQuery();
const { data: tokenData } = trpc.webImportSources.getToken.useQuery(
{ id: showToken! },
{ enabled: showToken !== null }
);
const createMutation = trpc.webImportSources.create.useMutation({
onSuccess: () => {
toast.success("Source créée", { description: "Le connecteur web a été ajouté." });
setShowForm(false);
setForm(emptyForm);
refetch();
},
onError: (e) => toast.error("Erreur", { description: e.message }),
});
const updateMutation = trpc.webImportSources.update.useMutation({
onSuccess: () => {
toast.success("Source mise à jour");
setEditSource(null);
setForm(emptyForm);
refetch();
},
onError: (e) => toast.error("Erreur", { description: e.message }),
});
const deleteMutation = trpc.webImportSources.delete.useMutation({
onSuccess: () => {
toast.success("Source supprimée");
setDeleteId(null);
refetch();
},
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);
setShowForm(true);
}
function openEdit(source: Source) {
setForm({
name: source.name,
connectorType: source.connectorType,
portalUrl: source.portalUrl,
loginEmail: source.loginEmail,
loginPassword: "", // Ne pas pré-remplir le mot de passe
frequency: source.frequency,
autoEnabled: source.autoEnabled,
});
setEditSource(source);
setShowForm(true);
}
function handleConnectorTypeChange(value: string) {
const connector = CONNECTOR_TYPES.find(c => c.value === value);
setForm(f => ({
...f,
connectorType: value,
name: f.name || connector?.label || "",
portalUrl: connector?.url || f.portalUrl,
}));
}
function handleSubmit() {
if (!form.name || !form.loginEmail || (!editSource && !form.loginPassword)) {
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, portalUrl };
if (!form.loginPassword) delete updateData.loginPassword; // Ne pas écraser si vide
updateMutation.mutate(updateData);
} else {
createMutation.mutate({ ...form, portalUrl });
}
}
return (
<DashboardLayout>
<div className="p-6 max-w-5xl mx-auto">
{/* En-tête */}
<div className="flex items-center justify-between mb-6">
<div className="flex items-center gap-3">
<div className="p-2 bg-indigo-100 rounded-lg">
<Globe className="w-6 h-6 text-indigo-600" />
</div>
<div>
<h1 className="text-2xl font-bold text-gray-900">Connecteurs web</h1>
<p className="text-sm text-gray-500">Import automatique de factures depuis des espaces clients</p>
</div>
</div>
<Button onClick={openCreate} className="bg-indigo-600 hover:bg-indigo-700 text-white gap-2">
<Plus className="w-4 h-4" />
Ajouter un connecteur
</Button>
</div>
{/* Info technique */}
<div className="bg-amber-50 border border-amber-200 rounded-lg p-4 mb-6 text-sm text-amber-800">
<strong>Fonctionnement :</strong> Un script cron tourne sur le serveur LWS et se connecte automatiquement aux espaces clients configurés pour télécharger les nouvelles factures. Le token API affiché ci-dessous est utilisé par ce script pour s'authentifier auprès de l'application.
</div>
{/* Liste des sources */}
{sources.length === 0 ? (
<div className="text-center py-16 text-gray-400">
<Globe className="w-12 h-12 mx-auto mb-3 opacity-30" />
<p className="text-lg font-medium">Aucun connecteur configuré</p>
<p className="text-sm mt-1">Ajoutez un connecteur pour importer automatiquement des factures depuis un espace client web.</p>
</div>
) : (
<div className="space-y-3">
{(sources as Source[]).map((source) => (
<div key={source.id} className="bg-white border border-gray-200 rounded-xl p-5 shadow-sm hover:shadow-md transition-shadow">
<div className="flex items-start justify-between gap-4">
<div className="flex items-start gap-4 flex-1 min-w-0">
<div className="p-2 bg-indigo-50 rounded-lg shrink-0">
<Globe className="w-5 h-5 text-indigo-500" />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className="font-semibold text-gray-900">{source.name}</span>
<Badge variant="outline" className="text-xs capitalize">{source.connectorType}</Badge>
<Badge variant={source.autoEnabled ? "default" : "secondary"} className="text-xs">
{source.autoEnabled ? "Auto activé" : "Manuel"}
</Badge>
<Badge variant="outline" className="text-xs">{FREQUENCY_LABELS[source.frequency]}</Badge>
</div>
<p className="text-sm text-gray-500 mt-1 truncate">{source.portalUrl}</p>
<p className="text-sm text-gray-600 mt-0.5">
<span className="font-medium">Identifiant :</span> {source.loginEmail}
</p>
<div className="flex items-center gap-4 mt-2 text-xs text-gray-400">
{source.lastSuccessAt ? (
<span className="flex items-center gap-1 text-green-600">
<CheckCircle className="w-3 h-3" />
Dernier import : {new Date(source.lastSuccessAt).toLocaleDateString("fr-FR")}
{source.lastImportCount !== null && ` (${source.lastImportCount} facture(s))`}
</span>
) : (
<span className="flex items-center gap-1 text-gray-400">
<Clock className="w-3 h-3" />
Jamais importé
</span>
)}
{source.lastStatus && !source.lastSuccessAt && (
<span className="flex items-center gap-1 text-red-500">
<XCircle className="w-3 h-3" />
{source.lastStatus}
</span>
)}
</div>
</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"
className="gap-1 text-xs"
onClick={() => setShowToken(showToken === source.id ? null : source.id)}
>
<Key className="w-3 h-3" />
Token
</Button>
<Button variant="outline" size="sm" onClick={() => openEdit(source)}>
<Pencil className="w-4 h-4" />
</Button>
<Button
variant="outline"
size="sm"
className="text-red-500 hover:text-red-700 hover:border-red-300"
onClick={() => setDeleteId(source.id)}
>
<Trash2 className="w-4 h-4" />
</Button>
</div>
</div>
{/* Token API affiché inline */}
{showToken === source.id && tokenData && (
<div className="mt-3 p-3 bg-gray-50 rounded-lg border border-gray-200">
<p className="text-xs text-gray-500 mb-1 font-medium">Token API (à configurer dans le script cron) :</p>
<code className="text-xs font-mono text-indigo-700 break-all select-all">{tokenData.apiToken}</code>
</div>
)}
</div>
))}
</div>
)}
{/* Dialog création / édition */}
<Dialog open={showForm} onOpenChange={(open) => { if (!open) { setShowForm(false); setEditSource(null); } }}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>{editSource ? "Modifier le connecteur" : "Nouveau connecteur web"}</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-2">
<div>
<Label>Type de connecteur</Label>
<Select value={form.connectorType} onValueChange={handleConnectorTypeChange}>
<SelectTrigger className="mt-1">
<SelectValue />
</SelectTrigger>
<SelectContent>
{CONNECTOR_TYPES.map(c => (
<SelectItem key={c.value} value={c.value}>{c.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<Label>Nom affiché</Label>
<Input
className="mt-1"
value={form.name}
onChange={e => setForm(f => ({ ...f, name: e.target.value }))}
placeholder="Ex : SFR Pro - Itinova"
/>
</div>
<div>
<Label>URL de l'espace client</Label>
<Input
className="mt-1"
value={form.portalUrl}
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>
<Input
className="mt-1"
value={form.loginEmail}
onChange={e => setForm(f => ({ ...f, loginEmail: e.target.value }))}
placeholder="votre@email.com"
/>
</div>
<div>
<Label>{editSource ? "Nouveau mot de passe (laisser vide pour ne pas changer)" : "Mot de passe"}</Label>
<Input
className="mt-1"
type="password"
value={form.loginPassword}
onChange={e => setForm(f => ({ ...f, loginPassword: e.target.value }))}
placeholder={editSource ? "••••••••" : "Mot de passe"}
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<Label>Fréquence</Label>
<Select value={form.frequency} onValueChange={(v: any) => setForm(f => ({ ...f, frequency: v }))}>
<SelectTrigger className="mt-1">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="manual">Manuel</SelectItem>
<SelectItem value="daily">Quotidien</SelectItem>
<SelectItem value="weekly">Hebdomadaire</SelectItem>
<SelectItem value="monthly">Mensuel</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex flex-col justify-end pb-1">
<div className="flex items-center gap-2">
<Switch
checked={form.autoEnabled === 1}
onCheckedChange={v => setForm(f => ({ ...f, autoEnabled: v ? 1 : 0 }))}
/>
<Label>Import auto activé</Label>
</div>
</div>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => { setShowForm(false); setEditSource(null); }}>Annuler</Button>
<Button
onClick={handleSubmit}
disabled={createMutation.isPending || updateMutation.isPending}
className="bg-indigo-600 hover:bg-indigo-700 text-white"
>
{createMutation.isPending || updateMutation.isPending ? (
<RefreshCw className="w-4 h-4 animate-spin mr-2" />
) : null}
{editSource ? "Enregistrer" : "Créer"}
</Button>
</DialogFooter>
</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>
<AlertDialogHeader>
<AlertDialogTitle>Supprimer ce connecteur ?</AlertDialogTitle>
<AlertDialogDescription>
Cette action est irréversible. Le connecteur et son token API seront supprimés.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Annuler</AlertDialogCancel>
<AlertDialogAction
className="bg-red-600 hover:bg-red-700"
onClick={() => deleteId !== null && deleteMutation.mutate({ id: deleteId })}
>
Supprimer
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</DashboardLayout>
);
}

View File

@@ -6,7 +6,7 @@ import bcrypt from "bcrypt";
async function createAdmin() {
const db = drizzle(process.env.DATABASE_URL);
const email = "adminServFacturation";
const email = "adminItinova";
const password = "Itinova69!";
const name = "Administrateur";
@@ -29,7 +29,7 @@ async function createAdmin() {
});
console.log("✅ Utilisateur administrateur créé avec succès !");
console.log("📧 Email/Login: adminServFacturation");
console.log("📧 Email/Login: adminItinova");
console.log("🔑 Mot de passe: Itinova69!");
console.log("👤 Rôle: admin");
} catch (error) {

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:

27
docs/maintenance.md Normal file
View File

@@ -0,0 +1,27 @@
# Notes de maintenance
## Stockage et sauvegardes
Les fichiers PDF et les exports de sauvegarde sont des **données d’exécution**. Ils sont volontairement exclus de Git par `storage/` et `backups/` afin qu’aucune facture ni sauvegarde ne soit envoyée au dépôt source. En recette et en production, ces dossiers doivent être montés sur des volumes Docker persistants.
Le module `server/databaseBackup.ts` produit un dump SQL autonome sans dépendre de `mysqldump`. Il exporte la structure de chaque table puis ses données par lots de 500 lignes. Il conserve au plus dix fichiers `.sql` locaux ; une sauvegarde est également téléchargée immédiatement depuis l’interface.
## Authentification et accès
Les routes de téléchargement BAP, de sauvegarde DB et de récupération de sauvegardes vérifient désormais la session JWT `auth_token`. Les sauvegardes DB sont strictement réservées aux administrateurs. Les cookies utilisent `Secure; SameSite=None` derrière HTTPS et basculent vers `SameSite=Lax` en HTTP local pour rester compatibles avec les navigateurs.
## Imports web
L’endpoint d’import web accepte uniquement des PDF de 20 Mo maximum, valide l’en-tête `%PDF`, normalise le nom du fichier et sauvegarde le document dans le stockage persistant avant analyse IA. Les erreurs détaillées restent dans les logs du serveur ; l’API retourne un message générique pour ne pas exposer de secret ou de détail d’infrastructure.
## Contrôles avant livraison
Avant chaque livraison, exécuter les commandes suivantes depuis la racine du projet :
```bash
pnpm test
pnpm run check
pnpm run build
```
Les scripts ponctuels d’exploitation ou contenant des identifiants ne doivent jamais rester dans le répertoire du projet ni être ajoutés au dépôt.

View File

@@ -0,0 +1 @@
ALTER TABLE `importLogs` ADD `importSource` enum('file','folder','email') DEFAULT 'file' NOT NULL;

View File

@@ -0,0 +1 @@
ALTER TABLE `users` MODIFY COLUMN `azureAdId` varchar(128);

View File

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

View File

@@ -0,0 +1,9 @@
CREATE TABLE `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`)
);

View File

@@ -0,0 +1,18 @@
CREATE TABLE `webImportSources` (
`id` int AUTO_INCREMENT NOT NULL,
`userId` int NOT NULL,
`name` varchar(100) NOT NULL,
`connectorType` varchar(50) NOT NULL,
`portalUrl` varchar(500) NOT NULL,
`loginEmail` varchar(320) NOT NULL,
`loginPassword` text NOT NULL,
`frequency` enum('manual','daily','weekly','monthly') NOT NULL DEFAULT 'monthly',
`autoEnabled` int NOT NULL DEFAULT 0,
`lastSuccessAt` timestamp,
`lastStatus` text,
`lastImportCount` int DEFAULT 0,
`apiToken` varchar(128) NOT NULL,
`createdAt` timestamp NOT NULL DEFAULT (now()),
`updatedAt` timestamp NOT NULL DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT `webImportSources_id` PRIMARY KEY(`id`)
);

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

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

@@ -225,6 +225,83 @@
"when": 1781021650065,
"tag": "0031_mean_squadron_supreme",
"breakpoints": true
},
{
"idx": 32,
"version": "5",
"when": 1781690960721,
"tag": "0032_volatile_jocasta",
"breakpoints": true
},
{
"idx": 33,
"version": "5",
"when": 1783419609559,
"tag": "0033_strange_manta",
"breakpoints": true
},
{
"idx": 34,
"version": "5",
"when": 1785167359371,
"tag": "0034_black_shadowcat",
"breakpoints": true
},
{
"idx": 35,
"version": "5",
"when": 1785404752594,
"tag": "0035_eminent_dreadnoughts",
"breakpoints": true
},
{
"idx": 36,
"version": "5",
"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.
@@ -9,7 +9,7 @@ export const users = mysqlTable("users", {
/** Manus OAuth identifier (openId) - Optional for backward compatibility */
openId: varchar("openId", { length: 64 }).unique(),
/** Azure AD Object ID - Unique identifier from Azure AD */
azureAdId: varchar("azureAdId", { length: 64 }).unique(),
azureAdId: varchar("azureAdId", { length: 128 }).unique(),
name: text("name"),
email: varchar("email", { length: 320 }).notNull().unique(),
/** Hashed password for local authentication (bcrypt) */
@@ -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,10 +153,11 @@ 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"]).default("mistral").notNull(), // AI provider: 'mistral' or 'manus'
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)
geminiApiKey: text("geminiApiKey"), // Google Gemini API key (overrides env GEMINI_API_KEY)
createdAt: timestamp("createdAt").defaultNow().notNull(),
updatedAt: timestamp("updatedAt").defaultNow().onUpdateNow().notNull(),
});
@@ -196,10 +201,11 @@ export const importSettings = mysqlTable("importSettings", {
azureSecretExpiresAt: timestamp("azureSecretExpiresAt"), // Azure AD Client Secret expiration date
// AI Engine settings
aiProvider: mysqlEnum("aiProvider", ["mistral", "manus"]).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)
geminiApiKey: text("geminiApiKey"), // Google Gemini API key (overrides env GEMINI_API_KEY)
createdAt: timestamp("createdAt").defaultNow().notNull(),
updatedAt: timestamp("updatedAt").defaultNow().onUpdateNow().notNull(),
@@ -223,6 +229,10 @@ export const importLogs = mysqlTable("importLogs", {
duplicateDetails: text("duplicateDetails"), // JSON array of duplicate invoice info
errorDetails: text("errorDetails"), // JSON array of error messages
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(),
});
@@ -238,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
@@ -314,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
@@ -504,3 +546,55 @@ export const freeproSettings = mysqlTable("freeproSettings", {
});
export type FreeproSettings = typeof freeproSettings.$inferSelect;
export type InsertFreeproSettings = typeof freeproSettings.$inferInsert;
/**
* Deleted invoices blacklist — prevents re-import of manually deleted invoices.
* When a user deletes an invoice, its invoiceNumber + totalAmount are stored here.
* The email/file import service checks this table before inserting a new invoice.
*/
export const deletedInvoices = mysqlTable("deletedInvoices", {
id: int("id").autoincrement().primaryKey(),
userId: int("userId").notNull(),
invoiceNumber: varchar("invoiceNumber", { length: 100 }).notNull(),
totalAmount: varchar("totalAmount", { length: 50 }),
supplierName: varchar("supplierName", { length: 255 }),
deletedAt: timestamp("deletedAt").defaultNow().notNull(),
});
export type DeletedInvoice = typeof deletedInvoices.$inferSelect;
export type InsertDeletedInvoice = typeof deletedInvoices.$inferInsert;
/**
* Web import sources — connecteurs web pour scraper des factures depuis des sites
* (ex: espace client SFR, Starlink, Orange...) avec login/mot de passe.
* Le scraping est exécuté par un script cron externe (Node.js + Playwright) sur LWS.
*/
export const webImportSources = mysqlTable("webImportSources", {
id: int("id").autoincrement().primaryKey(),
userId: int("userId").notNull(),
/** Nom affiché (ex: "SFR Pro", "Starlink") */
name: varchar("name", { length: 100 }).notNull(),
/** Type de connecteur — détermine le script Playwright à utiliser */
connectorType: varchar("connectorType", { length: 50 }).notNull(), // ex: "sfr", "starlink", "orange"
/** URL de l'espace client */
portalUrl: varchar("portalUrl", { length: 500 }).notNull(),
/** Identifiant de connexion (email ou login) */
loginEmail: varchar("loginEmail", { length: 320 }).notNull(),
/** Mot de passe chiffré (AES-256) */
loginPassword: text("loginPassword").notNull(),
/** Fréquence de vérification automatique */
frequency: mysqlEnum("frequency", ["manual", "daily", "weekly", "monthly"]).default("monthly").notNull(),
/** Activation de l'import automatique */
autoEnabled: int("autoEnabled").default(0).notNull(), // 0 = désactivé, 1 = activé
/** Date du dernier import réussi */
lastSuccessAt: timestamp("lastSuccessAt"),
/** Statut du dernier import */
lastStatus: text("lastStatus"),
/** Nombre de factures importées lors du dernier run */
lastImportCount: int("lastImportCount").default(0),
/** Token d'API pour que le script externe puisse s'authentifier */
apiToken: varchar("apiToken", { length: 128 }).notNull(),
createdAt: timestamp("createdAt").defaultNow().notNull(),
updatedAt: timestamp("updatedAt").defaultNow().onUpdateNow().notNull(),
});
export type WebImportSource = typeof webImportSources.$inferSelect;
export type InsertWebImportSource = typeof webImportSources.$inferInsert;

View File

@@ -10,7 +10,8 @@
"check": "tsc --noEmit",
"format": "prettier --write .",
"test": "vitest run",
"db:push": "drizzle-kit generate && drizzle-kit migrate"
"db:push": "drizzle-kit generate && drizzle-kit migrate",
"verify": "pnpm check && pnpm test && pnpm build"
},
"dependencies": {
"@aws-sdk/client-s3": "^3.693.0",
@@ -52,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",
@@ -70,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",
@@ -85,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",
@@ -95,7 +96,6 @@
"sharp": "^0.34.5",
"sonner": "^2.0.7",
"ssh2-sftp-client": "^12.0.1",
"streamdown": "^1.4.0",
"superjson": "^1.13.3",
"tailwind-merge": "^3.3.1",
"tailwindcss-animate": "^1.0.7",
@@ -105,16 +105,13 @@
"zod": "^4.1.12"
},
"devDependencies": {
"@builder.io/vite-plugin-jsx-loc": "^0.1.1",
"@tailwindcss/typography": "^0.5.15",
"@tailwindcss/vite": "^4.1.3",
"@types/express": "4.17.21",
"@types/google.maps": "^3.58.1",
"@types/node": "^24.7.0",
"@types/react": "^19.2.1",
"@types/react-dom": "^19.2.1",
"@vitejs/plugin-react": "^5.0.4",
"add": "^2.0.6",
"autoprefixer": "^10.4.20",
"drizzle-kit": "^0.31.4",
"esbuild": "^0.25.0",
@@ -141,4 +138,4 @@
"sharp"
]
}
}
}

2330
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

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

@@ -0,0 +1,70 @@
# Connecteurs Web - Scripts d'import automatique
## Prérequis sur le serveur LWS
```bash
cd /opt/web-import
npm install playwright
npx playwright install chromium --with-deps
```
## Configuration
1. Depuis l'application : **Configuration > Connecteurs web** → créer une source SFR → copier le token API
2. Créer un fichier de configuration :
```bash
cp config.example.json config.json
# Éditer config.json avec vos valeurs
```
Contenu de `config.json` :
```json
{
"sfrLogin": "votre-login@sfr.fr",
"sfrPassword": "votre-mot-de-passe",
"appUrl": "https://demat-facturation-santinova.santinova-soft.org",
"apiToken": "votre-token-api-copié-depuis-lappli",
"downloadDir": "/tmp/sfr-invoices",
"processedFile": "/tmp/sfr-processed.json"
}
```
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.santinova-soft.org
export API_TOKEN=votre-token-api
```
## Exécution manuelle
```bash
node sfr-connector.mjs
```
## Planification (cron)
Ajouter dans le crontab (`crontab -e`) :
```
# Import SFR le 5 de chaque mois à 8h00
0 8 5 * * /usr/bin/node /opt/web-import/sfr-connector.mjs >> /var/log/sfr-import.log 2>&1
```
## Ajouter un nouveau connecteur
Dupliquer `sfr-connector.mjs` et adapter :
1. L'URL du portail (`portalUrl`)
2. Les sélecteurs CSS pour le login et les liens de factures
3. Le nom du fichier de suivi (`processedFile`)
## Fonctionnement
1. Le script se connecte au site SFR avec les identifiants fournis
2. Il navigue vers la section factures
3. Il télécharge les PDFs non encore traités
4. Il les envoie à l'application via l'endpoint `/api/web-import/push-invoice`
5. L'application extrait les données avec l'IA et crée les factures
6. Le script marque les factures comme traitées pour éviter les doublons

View File

@@ -0,0 +1,216 @@
/**
* 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
*/
import { chromium } from 'playwright';
import fs from 'fs';
import path from 'path';
import https from 'https';
import http from 'http';
import { fileURLToPath } from 'url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
// ============ CONFIGURATION ============
// Ces variables peuvent être définies dans un fichier .env ou directement ici
const CONFIG = {
// URL de l'espace client SFR Pro
portalUrl: process.env.SFR_PORTAL_URL || 'https://www.sfr-business.fr/espace-client/',
// Identifiants SFR
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.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
downloadDir: process.env.DOWNLOAD_DIR || '/tmp/sfr-invoices',
// Ne pas réimporter les factures déjà traitées (fichier de suivi)
processedFile: process.env.PROCESSED_FILE || '/tmp/sfr-processed.json',
};
// ============ HELPERS ============
function log(msg) {
console.log(`[${new Date().toISOString()}] [SFR] ${msg}`);
}
function loadProcessed() {
try {
if (fs.existsSync(CONFIG.processedFile)) {
return JSON.parse(fs.readFileSync(CONFIG.processedFile, 'utf8'));
}
} catch {}
return [];
}
function saveProcessed(list) {
fs.writeFileSync(CONFIG.processedFile, JSON.stringify(list, null, 2));
}
async function pushInvoiceToApp(filePath, fileName) {
const fileBuffer = fs.readFileSync(filePath);
const fileBase64 = fileBuffer.toString('base64');
const body = JSON.stringify({
apiToken: CONFIG.apiToken,
fileName,
fileBase64,
mimeType: 'application/pdf',
});
return new Promise((resolve, reject) => {
const url = new URL(`${CONFIG.appUrl}/api/web-import/push-invoice`);
const options = {
hostname: url.hostname,
port: url.port || (url.protocol === 'https:' ? 443 : 80),
path: url.pathname,
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(body),
},
};
const lib = url.protocol === 'https:' ? https : http;
const req = lib.request(options, (res) => {
let data = '';
res.on('data', chunk => data += chunk);
res.on('end', () => {
try {
resolve({ status: res.statusCode, body: JSON.parse(data) });
} catch {
resolve({ status: res.statusCode, body: data });
}
});
});
req.on('error', reject);
req.write(body);
req.end();
});
}
// ============ 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);
}
// Créer le dossier de téléchargement
if (!fs.existsSync(CONFIG.downloadDir)) {
fs.mkdirSync(CONFIG.downloadDir, { recursive: true });
}
const processed = loadProcessed();
let newInvoices = 0;
const browser = await chromium.launch({ headless: true });
const context = await browser.newContext({
acceptDownloads: true,
});
const page = await context.newPage();
try {
// 1. Naviguer vers l'espace client SFR
log(`Navigation vers ${CONFIG.portalUrl}`);
await page.goto(CONFIG.portalUrl, { waitUntil: 'networkidle', timeout: 30000 });
// 2. Accepter les cookies si présent
try {
await page.click('[id*="accept"], [class*="accept-cookie"], #didomi-notice-agree-button', { timeout: 3000 });
log('Cookies acceptés');
} catch {}
// 3. Remplir le formulaire de connexion
log('Connexion en cours...');
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]',
links => links.map(a => ({
href: a.href,
text: a.textContent?.trim() || '',
download: a.getAttribute('download') || '',
}))
);
log(`${invoiceLinks.length} lien(s) de facture trouvé(s)`);
// 5. Télécharger et envoyer chaque facture
for (const link of invoiceLinks) {
const invoiceId = link.href || link.text;
if (processed.includes(invoiceId)) {
log(`Déjà traité : ${link.text}`);
continue;
}
try {
// Télécharger le PDF
const [download] = await Promise.all([
context.waitForEvent('download', { timeout: 15000 }),
page.click(`a[href="${link.href}"]`).catch(() => page.goto(link.href)),
]);
const fileName = download?.suggestedFilename() || `sfr-facture-${Date.now()}.pdf`;
const filePath = path.join(CONFIG.downloadDir, fileName);
await download?.saveAs(filePath);
log(`Téléchargé : ${fileName}`);
// Envoyer à l'application
const result = await pushInvoiceToApp(filePath, fileName);
log(`Envoyé : ${fileName} → ${JSON.stringify(result.body)}`);
// Marquer comme traité
processed.push(invoiceId);
saveProcessed(processed);
newInvoices++;
// Nettoyer le fichier temporaire
fs.unlinkSync(filePath);
} catch (err) {
log(`ERREUR sur ${link.text} : ${err.message}`);
}
}
} catch (err) {
log(`ERREUR FATALE : ${err.message}`);
await page.screenshot({ path: path.join(CONFIG.downloadDir, 'error-screenshot.png') }).catch(() => {});
throw err;
} finally {
await browser.close();
}
log(`Terminé : ${newInvoices} nouvelle(s) facture(s) importée(s)`);
return newInvoices;
}
// ============ POINT D'ENTRÉE ============
runSfrConnector().catch(err => {
console.error(`[FATAL] ${err.message}`);
process.exit(1);
});

View File

@@ -0,0 +1,30 @@
import { describe, expect, it } from "vitest";
import type { Request } from "express";
import { getSessionCookieOptions } from "./cookies";
function requestFor(protocol: "http" | "https", forwardedProto?: string): Request {
return {
protocol,
headers: forwardedProto ? { "x-forwarded-proto": forwardedProto } : {},
} as Request;
}
describe("getSessionCookieOptions", () => {
it("utilise des cookies sécurisés derrière le proxy HTTPS", () => {
expect(getSessionCookieOptions(requestFor("http", "https"))).toMatchObject({
httpOnly: true,
path: "/",
secure: true,
sameSite: "none",
});
});
it("reste compatible avec l’environnement HTTP local", () => {
expect(getSessionCookieOptions(requestFor("http"))).toMatchObject({
httpOnly: true,
path: "/",
secure: false,
sameSite: "lax",
});
});
});

View File

@@ -1,13 +1,6 @@
import type { CookieOptions, Request } from "express";
const LOCAL_HOSTS = new Set(["localhost", "127.0.0.1", "::1"]);
function isIpAddress(host: string) {
// Basic IPv4 check and IPv6 presence detection.
if (/^\d{1,3}(\.\d{1,3}){3}$/.test(host)) return true;
return host.includes(":");
}
/** Detects HTTPS after direct access or a reverse proxy such as Traefik. */
function isSecureRequest(req: Request) {
if (req.protocol === "https") return true;
@@ -24,25 +17,13 @@ function isSecureRequest(req: Request) {
export function getSessionCookieOptions(
req: Request
): Pick<CookieOptions, "domain" | "httpOnly" | "path" | "sameSite" | "secure"> {
// const hostname = req.hostname;
// const shouldSetDomain =
// hostname &&
// !LOCAL_HOSTS.has(hostname) &&
// !isIpAddress(hostname) &&
// hostname !== "127.0.0.1" &&
// hostname !== "::1";
// const domain =
// shouldSetDomain && !hostname.startsWith(".")
// ? `.${hostname}`
// : shouldSetDomain
// ? hostname
// : undefined;
const secure = isSecureRequest(req);
return {
httpOnly: true,
path: "/",
sameSite: "none",
secure: isSecureRequest(req),
// Browsers reject SameSite=None without Secure; use Lax for local HTTP.
sameSite: secure ? "none" : "lax",
secure,
};
}

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 ?? "",
@@ -8,4 +26,10 @@ export const ENV = {
forgeApiUrl: process.env.BUILT_IN_FORGE_API_URL ?? "",
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

@@ -5,15 +5,45 @@ import net from "net";
import path from "path";
import fs from "fs";
import archiver from "archiver";
import { parse as parseCookies } from "cookie";
import { createExpressMiddleware } from "@trpc/server/adapters/express";
import { registerOAuthRoutes } from "./oauth";
import { appRouter } from "../routers";
import { createContext } from "./context";
import { getSessionCookieOptions } from "./cookies";
import { serveStatic, setupVite } from "./vite";
import { getAllUsers } from "../db";
import { getAllUsers, getImportSettingsByUser, getUserByAzureAdId, getUserByEmail, getUserSettings, upsertUser } from "../db";
import { startEmailImportService } from "../emailImportService";
import { startFolderImportService } from "../folderImportService";
import { getImportSettingsByUser } from "../db";
import { handleAzureCallback, isAzureAdConfigured, generateToken, verifyToken } from "../auth";
import { createDatabaseBackup } from "../databaseBackup";
import { generateStorageKey, localStorageDelete, localStoragePut } from "../localStorage";
import { calculateFileSha256 } from "../fileFingerprint";
import { withCalculatedInvoiceQuality } from "@shared/invoiceQuality";
const MAX_WEB_IMPORT_BYTES = 20 * 1024 * 1024;
/** Returns the signed local session or sends the appropriate HTTP error. */
function requireAuthenticatedUser(req: express.Request, res: express.Response) {
const token = parseCookies(req.headers.cookie || "").auth_token;
const user = token ? verifyToken(token) : null;
if (!user) {
res.status(401).json({ error: "Non authentifié" });
return null;
}
return user;
}
/** Restricts a sensitive endpoint to administrators. */
function requireAdmin(req: express.Request, res: express.Response) {
const user = requireAuthenticatedUser(req, res);
if (!user) return null;
if (user.role !== "admin") {
res.status(403).json({ error: "Accès réservé aux administrateurs" });
return null;
}
return user;
}
function isPortAvailable(port: number): Promise<boolean> {
return new Promise(resolve => {
@@ -49,6 +79,7 @@ async function startServer() {
// Accepte les chemins avec sous-dossiers : /api/download-bap/2026-04/filename.pdf
// ou via query param pdfPath : /api/download-bap/file.pdf?pdfPath=/storage/2026-04/file.pdf
app.get("/api/download-bap", (req, res) => {
if (!requireAuthenticatedUser(req, res)) return;
// Mode 1 : query param pdfPath (chemin complet depuis /storage/...)
const pdfPath = req.query.pdfPath as string | undefined;
if (!pdfPath) {
@@ -79,6 +110,7 @@ async function startServer() {
// Compat. ancienne route avec :filename (sans sous-dossier)
app.get("/api/download-bap/:filename", (req, res) => {
if (!requireAuthenticatedUser(req, res)) return;
const filename = path.basename(req.params.filename);
// Chercher dans tous les sous-dossiers de storage
const storageRoot = path.resolve("storage");
@@ -105,6 +137,7 @@ async function startServer() {
// Route de téléchargement groupé ZIP des PDFs annotés BAP
// POST /api/download-bap-zip avec body { files: Array<{ pdfPath: string, filename: string }> }
app.post("/api/download-bap-zip", (req, res) => {
if (!requireAuthenticatedUser(req, res)) return;
const files: Array<{ pdfPath: string; filename: string }> = req.body.files || [];
if (!files.length) {
res.status(400).json({ error: "Aucun fichier spécifié" });
@@ -143,6 +176,206 @@ async function startServer() {
archive.finalize();
});
// ============= AZURE AD OAUTH2 CALLBACK =============
app.get("/api/auth/azure/callback", async (req, res) => {
const code = req.query.code as string | undefined;
const error = req.query.error as string | undefined;
if (error) {
console.error("[Azure AD] Erreur OAuth:", error, req.query.error_description);
res.redirect(`/login?error=${encodeURIComponent("Connexion Microsoft refusée")}`);
return;
}
if (!code) {
res.redirect("/login?error=" + encodeURIComponent("Code OAuth manquant"));
return;
}
if (!isAzureAdConfigured()) {
res.redirect("/login?error=" + encodeURIComponent("Azure AD non configuré"));
return;
}
try {
const azureUser = await handleAzureCallback(code);
// Chercher l'utilisateur par azureAdId ou par email
let user = await getUserByAzureAdId(azureUser.azureAdId);
if (!user) {
user = await getUserByEmail(azureUser.email);
}
if (!user) {
// Créer l'utilisateur automatiquement
await upsertUser({
email: azureUser.email,
name: azureUser.name,
azureAdId: azureUser.azureAdId,
loginMethod: "azure-ad",
isActive: 1,
role: "user",
});
user = await getUserByEmail(azureUser.email);
} else {
// Mettre à jour l'azureAdId si manquant
if (!user.azureAdId) {
await upsertUser({
email: user.email,
azureAdId: azureUser.azureAdId,
loginMethod: user.loginMethod,
});
}
}
if (!user) {
res.redirect("/login?error=" + encodeURIComponent("Impossible de créer le compte"));
return;
}
if (user.isActive === 0) {
res.redirect("/login?error=" + encodeURIComponent("Compte inactif"));
return;
}
// Générer le token JWT et poser le cookie
const token = generateToken(user);
res.cookie("auth_token", token, {
...getSessionCookieOptions(req),
maxAge: 7 * 24 * 60 * 60 * 1000,
});
console.log(`[Azure AD] Connexion réussie pour ${user.email}`);
res.redirect("/");
} catch (err: any) {
console.error("[Azure AD] Erreur callback:", err.message);
res.redirect("/login?error=" + encodeURIComponent("Erreur d'authentification Microsoft"));
}
});
// ============= WEB IMPORT SOURCES - Endpoint pour script cron externe =============
// ============= DB BACKUP - Génération et téléchargement dump MySQL =============
app.post("/api/db-backup", async (req, res) => {
if (!requireAdmin(req, res)) return;
try {
const backupDir = path.resolve("backups");
const backup = await createDatabaseBackup(process.env.DATABASE_URL, backupDir);
console.log(`[Backup] Dump saved to ${backup.filePath} (${backup.size} bytes)`);
// Retourner le fichier en téléchargement
const encodedName = encodeURIComponent(backup.fileName);
res.setHeader("Content-Disposition", `attachment; filename="${encodedName}"; filename*=UTF-8''${encodedName}`);
res.setHeader("Content-Type", "application/sql");
res.sendFile(backup.filePath, (err) => {
if (err) console.error("[Backup] Error sending file:", err);
});
} catch (err: any) {
console.error("[Backup] Error:", err.message);
res.status(500).json({ error: "La sauvegarde n’a pas pu être générée. Consultez les journaux serveur." });
}
});
// Télécharger une sauvegarde existante
app.get("/api/db-backup/:filename", async (req, res) => {
if (!requireAdmin(req, res)) return;
const fileName = path.basename(req.params.filename);
const filePath = path.join(path.resolve("backups"), fileName);
if (!fs.existsSync(filePath)) { res.status(404).json({ error: "Fichier introuvable" }); return; }
const encodedName = encodeURIComponent(fileName);
res.setHeader("Content-Disposition", `attachment; filename="${encodedName}"; filename*=UTF-8''${encodedName}`);
res.setHeader("Content-Type", "application/sql");
res.sendFile(filePath);
});
app.post("/api/web-import/push-invoice", async (req, res) => {
try {
const { apiToken, fileName, fileBase64 } = req.body;
if (typeof apiToken !== "string" || typeof fileName !== "string" || typeof fileBase64 !== "string") {
res.status(400).json({ error: "apiToken, fileName et fileBase64 sont requis" });
return;
}
const safeFileName = path.basename(fileName);
if (!safeFileName.toLowerCase().endsWith(".pdf")) {
res.status(400).json({ error: "Seuls les fichiers PDF sont acceptés" });
return;
}
if (Buffer.byteLength(fileBase64, "utf8") > Math.ceil(MAX_WEB_IMPORT_BYTES * 1.34)) {
res.status(413).json({ error: "Le fichier dépasse la taille maximale autorisée" });
return;
}
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" });
return;
}
const pdfBuffer = Buffer.from(fileBase64, 'base64');
if (pdfBuffer.length === 0 || pdfBuffer.length > MAX_WEB_IMPORT_BYTES || !pdfBuffer.subarray(0, 4).equals(Buffer.from("%PDF"))) {
res.status(400).json({ error: "Le contenu reçu n’est pas un PDF valide" });
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");
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,
manusForgeApiUrl: userSettings?.manusForgeApiUrl || undefined,
manusForgeApiKey: userSettings?.manusForgeApiKey || undefined,
geminiApiKey: userSettings?.geminiApiKey || undefined,
};
const { extractInvoicesWithMistral } = await import('../invoiceExtractor');
const extractResult = await extractInvoicesWithMistral(pdfBuffer, source.userId, sourceFile.id, userSettings?.llmModel || "mistral-large-latest", undefined, aiSettings);
let imported = 0;
let duplicates = 0;
for (const inv of extractResult.invoices || []) {
const dup = await findDuplicateInvoice(inv.invoiceNumber || null, String(inv.totalAmount ?? ''), source.userId);
if (dup) { duplicates++; continue; }
const invoiceWithQuality = withCalculatedInvoiceQuality(inv, qualityFieldsConfig);
await createInvoice({ ...invoiceWithQuality, userId: source.userId, sourceFileId: sourceFile.id } as any);
imported++;
}
await updateWebImportSourceStatus(source.id, 'success', imported, true);
res.json({ success: true, imported, duplicates, total: (extractResult.invoices || []).length });
} catch (err: any) {
console.error('[WebImport] Erreur push-invoice:', err.message);
res.status(500).json({ error: "L’import web a échoué. Consultez les journaux serveur." });
}
});
// tRPC API
app.use(
"/api/trpc",
@@ -195,4 +428,7 @@ async function startServer() {
}, 5000); // Attendre 5s que le serveur soit prêt
}
startServer().catch(console.error);
// Vitest ne doit jamais démarrer un serveur HTTP.
if (!process.env.VITEST) {
startServer().catch(console.error);
}

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,40 +307,69 @@ 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 }): string {
function resolveApiUrlWithSettings(settings?: UserLlmSettings): string {
const provider = settings?.aiProvider || "mistral";
if (provider === "mistral") {
return "https://api.mistral.ai/v1/chat/completions";
}
if (provider === "gemini") {
// Google AI Studio — interface compatible OpenAI
return "https://generativelanguage.googleapis.com/v1beta/openai/chat/completions";
}
// Manus provider
const forgeUrl = settings?.manusForgeApiUrl || ENV.forgeApiUrl || "https://forge.manus.ai";
return `${forgeUrl.replace(/\/$/, "")}/v1/chat/completions`;
}
function getApiKeyWithSettings(settings?: { aiProvider?: string | null; mistralApiKey?: string | null; manusForgeApiKey?: string | null }): string {
function getApiKeyWithSettings(settings?: UserLlmSettings): string {
const provider = settings?.aiProvider || "mistral";
if (provider === "mistral") {
return settings?.mistralApiKey || ENV.mistralApiKey || "";
}
if (provider === "gemini") {
return settings?.geminiApiKey || ENV.geminiApiKey || "";
}
return settings?.manusForgeApiKey || ENV.forgeApiKey || "";
}
/**
* 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" };
}
/**
* Version de invokeLLM qui accepte les paramètres utilisateur depuis la DB
* pour choisir dynamiquement le moteur IA (Mistral ou Manus).
* pour choisir dynamiquement le moteur IA (Mistral, Manus ou Gemini).
*/
export async function invokeLLMWithUserSettings(
params: InvokeParams,
userSettings?: { aiProvider?: string | null; mistralApiKey?: string | null; manusForgeApiKey?: string | null; manusForgeApiUrl?: 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 isMistral = provider === "mistral";
const provider = trace.provider;
const {
messages,
tools,
@@ -328,7 +381,7 @@ export async function invokeLLMWithUserSettings(
response_format,
} = params;
const model = isMistral ? "mistral-large-latest" : "gemini-2.5-flash";
const model = trace.model;
const payload: Record<string, unknown> = {
model,
@@ -342,7 +395,8 @@ export async function invokeLLMWithUserSettings(
payload.max_tokens = 32768;
if (!isMistral) {
// thinking uniquement pour Manus Forge (proxy Gemini)
if (provider === "manus") {
payload.thinking = { budget_tokens: 128 };
}
@@ -364,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

@@ -1,6 +1,7 @@
import { COOKIE_NAME, ONE_YEAR_MS } from "@shared/const";
import type { Express, Request, Response } from "express";
import * as db from "../db";
import { normalizeLoginMethod } from "../authMethod";
import { getSessionCookieOptions } from "./cookies";
import { sdk } from "./sdk";
@@ -32,7 +33,7 @@ export function registerOAuthRoutes(app: Express) {
openId: userInfo.openId,
name: userInfo.name || null,
email: userInfo.email ?? "unknown@example.com",
loginMethod: (userInfo.loginMethod ?? userInfo.platform ?? "manus") as "manus" | "local" | "azure-ad",
loginMethod: normalizeLoginMethod(userInfo.loginMethod ?? userInfo.platform),
lastSignedIn: new Date(),
});

View File

@@ -2,6 +2,7 @@ import { AXIOS_TIMEOUT_MS, COOKIE_NAME, ONE_YEAR_MS } from "@shared/const";
import { ForbiddenError } from "@shared/_core/errors";
import axios, { type AxiosInstance } from "axios";
import { parse as parseCookieHeader } from "cookie";
import { normalizeLoginMethod } from "../authMethod";
import type { Request } from "express";
import { SignJWT, jwtVerify } from "jose";
import type { User } from "../../drizzle/schema";
@@ -296,7 +297,7 @@ class SDKServer {
openId: userInfo.openId,
name: userInfo.name || null,
email: userInfo.email ?? "unknown@example.com",
loginMethod: (userInfo.loginMethod ?? userInfo.platform ?? "manus") as "manus" | "local" | "azure-ad",
loginMethod: normalizeLoginMethod(userInfo.loginMethod ?? userInfo.platform),
lastSignedIn: signedInAt,
});
user = await db.getUserByOpenId(userInfo.openId);

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

@@ -1,6 +1,6 @@
import bcrypt from "bcrypt";
import { ConfidentialClientApplication } from "@azure/msal-node";
import { getUserByEmail, getUserByAzureAdId } from "./db";
import { getUserByEmail } from "./db";
import jwt from "jsonwebtoken";
const SALT_ROUNDS = 10;

19
server/authMethod.test.ts Normal file
View File

@@ -0,0 +1,19 @@
import { describe, expect, it } from "vitest";
import { normalizeLoginMethod } from "./authMethod";
describe("normalizeLoginMethod", () => {
it("conserve les valeurs de l’enum DB", () => {
expect(normalizeLoginMethod("local")).toBe("local");
expect(normalizeLoginMethod("azure-ad")).toBe("azure-ad");
});
it("convertit les identifiants de fournisseur Microsoft", () => {
expect(normalizeLoginMethod("Microsoft OAuth")).toBe("azure-ad");
expect(normalizeLoginMethod("AZURE_ENTRA")).toBe("azure-ad");
});
it("utilise Manus pour toute valeur inconnue au lieu d’échouer en base", () => {
expect(normalizeLoginMethod("platform-v2")).toBe("manus");
expect(normalizeLoginMethod(undefined)).toBe("manus");
});
});

23
server/authMethod.ts Normal file
View File

@@ -0,0 +1,23 @@
/** Values accepted by the `users.loginMethod` database enum. */
export type LoginMethod = "manus" | "local" | "azure-ad";
const LOGIN_METHODS = new Set<LoginMethod>(["manus", "local", "azure-ad"]);
/**
* Maps provider-specific identifiers to the limited database enum.
* OAuth identity payloads are external input and must never be persisted verbatim.
*/
export function normalizeLoginMethod(value: unknown): LoginMethod {
if (typeof value !== "string") return "manus";
const normalized = value.trim().toLowerCase();
if (LOGIN_METHODS.has(normalized as LoginMethod)) {
return normalized as LoginMethod;
}
if (normalized.includes("azure") || normalized.includes("microsoft")) {
return "azure-ad";
}
return "manus";
}

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

@@ -0,0 +1,21 @@
import { describe, expect, it } from "vitest";
import { toSqlLiteral } from "./databaseBackup";
describe("toSqlLiteral", () => {
it("sérialise les valeurs primitives de manière importable", () => {
expect(toSqlLiteral(null)).toBe("NULL");
expect(toSqlLiteral(undefined)).toBe("NULL");
expect(toSqlLiteral(42.5)).toBe("42.5");
expect(toSqlLiteral(true)).toBe("1");
expect(toSqlLiteral(false)).toBe("0");
});
it("échappe les caractères sensibles d’une chaîne SQL", () => {
expect(toSqlLiteral("O'Hara\\facture\nligne")).toBe("'O\\'Hara\\\\facture\\nligne'");
});
it("conserve les données binaires et dates sans conversion ambiguë", () => {
expect(toSqlLiteral(Buffer.from([0, 255]))).toBe("X'00ff'");
expect(toSqlLiteral(new Date("2026-08-17T12:34:56.000Z"))).toBe("'2026-08-17 12:34:56.000'");
});
});

170
server/databaseBackup.ts Normal file
View File

@@ -0,0 +1,170 @@
import fs from "fs/promises";
import path from "path";
import mysql, { type RowDataPacket } from "mysql2/promise";
/** Number of rows exported per INSERT statement to bound memory usage. */
const EXPORT_BATCH_SIZE = 500;
/** Keep a short local history while preventing unbounded disk growth. */
const MAX_BACKUP_FILES = 10;
export type DatabaseBackupResult = {
fileName: string;
filePath: string;
size: number;
tableCount: number;
};
/**
* Converts one MySQL value to a portable SQL literal.
* Binary values, booleans, dates, quotes and backslashes are handled explicitly
* so a generated dump can be imported without corrupting invoice data.
*/
export function toSqlLiteral(value: unknown): string {
if (value === null || value === undefined) return "NULL";
if (Buffer.isBuffer(value)) return `X'${value.toString("hex")}'`;
if (value instanceof Date) {
return `'${value.toISOString().replace("T", " ").replace("Z", "")}'`;
}
if (typeof value === "number") return Number.isFinite(value) ? String(value) : "NULL";
if (typeof value === "boolean") return value ? "1" : "0";
return `'${String(value)
.replace(/\\/g, "\\\\")
.replace(/'/g, "\\'")
.replace(/\u0000/g, "\\0")
.replace(/\n/g, "\\n")
.replace(/\r/g, "\\r")}'`;
}
function quoteIdentifier(identifier: string): string {
if (!/^[A-Za-z0-9_$]+$/.test(identifier)) {
throw new Error("Identifiant SQL inattendu lors de la sauvegarde");
}
return `\`${identifier}\``;
}
function buildFileName(database: string, now = new Date()): string {
const safeDatabase = database.replace(/[^A-Za-z0-9_-]/g, "_");
const timestamp = now.toISOString().replace(/[:.]/g, "-").slice(0, 19);
return `backup-${safeDatabase}-${timestamp}.sql`;
}
function buildSslConfig(databaseUrl: URL) {
const sslMode = databaseUrl.searchParams.get("ssl-mode")?.toUpperCase();
const sslParameter = databaseUrl.searchParams.get("ssl");
if (sslMode === "REQUIRED" || sslMode === "VERIFY_CA" || sslMode === "VERIFY_IDENTITY") {
return { rejectUnauthorized: sslMode === "VERIFY_IDENTITY" };
}
if (sslParameter && sslParameter !== "false") {
try {
return JSON.parse(sslParameter) as { rejectUnauthorized?: boolean };
} catch {
return { rejectUnauthorized: false };
}
}
return undefined;
}
async function pruneOldBackups(backupDir: string): Promise<void> {
const entries = await fs.readdir(backupDir, { withFileTypes: true });
const backups = await Promise.all(
entries
.filter(entry => entry.isFile() && entry.name.endsWith(".sql"))
.map(async entry => ({
name: entry.name,
modifiedAt: (await fs.stat(path.join(backupDir, entry.name))).mtimeMs,
}))
);
backups.sort((a, b) => b.modifiedAt - a.modifiedAt);
await Promise.all(
backups.slice(MAX_BACKUP_FILES).map(backup => fs.unlink(path.join(backupDir, backup.name)))
);
}
/**
* Creates a self-contained SQL dump without requiring the mysqldump binary.
* Rows are exported in batches to avoid keeping the entire database in memory.
*/
export async function createDatabaseBackup(
databaseUrlValue: string | undefined,
backupDir: string
): Promise<DatabaseBackupResult> {
if (!databaseUrlValue) {
throw new Error("DATABASE_URL est absente");
}
const databaseUrl = new URL(databaseUrlValue);
if (!databaseUrl.protocol.startsWith("mysql")) {
throw new Error("La sauvegarde requiert une base de données MySQL compatible");
}
const database = databaseUrl.pathname.replace(/^\//, "");
if (!database) {
throw new Error("Nom de base de données absent de DATABASE_URL");
}
await fs.mkdir(backupDir, { recursive: true });
const fileName = buildFileName(database);
const filePath = path.join(backupDir, fileName);
const connection = await mysql.createConnection({
host: databaseUrl.hostname,
port: Number(databaseUrl.port || "3306"),
user: decodeURIComponent(databaseUrl.username),
password: decodeURIComponent(databaseUrl.password),
database,
ssl: buildSslConfig(databaseUrl),
});
try {
await fs.writeFile(
filePath,
`-- Backup généré le ${new Date().toISOString()}\n-- Base : ${database}\nSET FOREIGN_KEY_CHECKS=0;\n\n`,
"utf8"
);
const [tables] = await connection.query<RowDataPacket[]>("SHOW TABLES");
const tableNames = tables.map(row => String(Object.values(row)[0]));
for (const tableName of tableNames) {
const table = quoteIdentifier(tableName);
const [createRows] = await connection.query<RowDataPacket[]>(`SHOW CREATE TABLE ${table}`);
const createStatement = String(createRows[0]?.["Create Table"] ?? "");
if (!createStatement) throw new Error(`Structure introuvable pour la table ${tableName}`);
await fs.appendFile(
filePath,
`-- Table: ${tableName}\nDROP TABLE IF EXISTS ${table};\n${createStatement};\n\n`,
"utf8"
);
let offset = 0;
while (true) {
const [rows] = await connection.query<RowDataPacket[]>(
`SELECT * FROM ${table} LIMIT ${EXPORT_BATCH_SIZE} OFFSET ${offset}`
);
if (rows.length === 0) break;
const columns = Object.keys(rows[0]!).map(quoteIdentifier).join(", ");
const values = rows
.map(row => `(${Object.values(row).map(toSqlLiteral).join(", ")})`)
.join(",\n");
await fs.appendFile(filePath, `INSERT INTO ${table} (${columns}) VALUES\n${values};\n\n`, "utf8");
offset += rows.length;
}
}
await fs.appendFile(filePath, "SET FOREIGN_KEY_CHECKS=1;\n-- Fin du dump\n", "utf8");
await pruneOldBackups(backupDir);
const { size } = await fs.stat(filePath);
return { fileName, filePath, size, tableCount: tableNames.length };
} catch (error) {
await fs.rm(filePath, { force: true });
throw error;
} finally {
await connection.end();
}
}

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,
@@ -43,10 +46,14 @@ import {
InsertBapHistory,
BapHistory,
invoiceLearnings,
InsertInvoiceLearning,
InvoiceLearning
InvoiceLearning,
deletedInvoices,
webImportSources,
InsertWebImportSource,
WebImportSource
} from "../drizzle/schema";
import { ENV } from './_core/env';
import { buildAnnualInvoiceSummary } from "@shared/invoiceAnalytics";
let _db: ReturnType<typeof drizzle> | null = null;
@@ -201,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;
@@ -208,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");
@@ -265,14 +299,58 @@ export async function updateInvoice(id: number, data: Partial<Invoice>) {
export async function deleteInvoice(id: number) {
const db = await getDb();
if (!db) throw new Error("Database not available");
// 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) {
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));
}
export async function searchInvoices(userId: number, query: string): Promise<Invoice[]> {
/** Vérifie si une facture est dans la blacklist (supprimée manuellement) */
export async function isInvoiceBlacklisted(
invoiceNumber: string | null,
userId: number
): Promise<boolean> {
if (!invoiceNumber) return false;
const db = await getDb();
if (!db) return false;
const result = await db.select().from(deletedInvoices)
.where(and(eq(deletedInvoices.userId, userId), eq(deletedInvoices.invoiceNumber, invoiceNumber)))
.limit(1);
return result.length > 0;
}
export async function searchInvoices(userId: number | null, query: string): Promise<Invoice[]> {
const db = await getDb();
if (!db) return [];
const searchPattern = `%${query}%`;
if (userId === null) {
// Admin : recherche globale sans filtre userId
return db.select().from(invoices)
.where(sql`(${invoices.supplierName} LIKE ${searchPattern} OR ${invoices.invoiceNumber} LIKE ${searchPattern})`)
.orderBy(desc(invoices.createdAt));
}
return db.select().from(invoices)
.where(
and(
@@ -283,7 +361,7 @@ export async function searchInvoices(userId: number, query: string): Promise<Inv
.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,
@@ -297,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");
@@ -409,7 +489,8 @@ export async function getInvoiceStats(userId: number) {
topSuppliers,
suppliersList,
topRecipients,
recipientsList
recipientsList,
annualSummary: buildAnnualInvoiceSummary(allInvoices),
};
}
@@ -485,10 +566,85 @@ export async function getAllImportLogs(): Promise<ImportLog[]> {
return db.select().from(importLogs).orderBy(desc(importLogs.importedAt));
}
export async function deleteAllImportLogs(userId: number): Promise<void> {
/**
* 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");
await db.delete(importLogs).where(eq(importLogs.userId, userId));
if (userId === null) {
// Admin : supprime tous les logs
await db.delete(importLogs);
} else {
await db.delete(importLogs).where(eq(importLogs.userId, userId));
}
}
/** 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 =============
@@ -553,12 +709,30 @@ export async function upsertImportSettings(data: InsertImportSettings): Promise<
}
}
// ============= DEPARTMENT LIST OPERATIONS =============
export async function getDepartmentsByUser(userId: number): Promise<Department[]> {
/** 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 await db.select().from(departmentList).where(eq(departmentList.userId, userId)).orderBy(departmentList.name);
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[]> {
const db = await getDb();
if (!db) return [];
return await db.select().from(departmentList).orderBy(departmentList.name);
}
export async function createDepartment(data: InsertDepartment): Promise<Department> {
@@ -576,10 +750,10 @@ export async function deleteDepartment(id: number): Promise<void> {
// ============= ACCOUNTING ALLOCATION LIST OPERATIONS =============
export async function getAccountingAllocationsByUser(userId: number): Promise<AccountingAllocation[]> {
export async function getAccountingAllocationsByUser(_userId?: number): Promise<AccountingAllocation[]> {
const db = await getDb();
if (!db) return [];
return await db.select().from(accountingAllocationList).where(eq(accountingAllocationList.userId, userId)).orderBy(accountingAllocationList.name);
return await db.select().from(accountingAllocationList).orderBy(accountingAllocationList.name);
}
export async function createAccountingAllocation(data: InsertAccountingAllocation): Promise<AccountingAllocation> {
@@ -633,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> {
@@ -731,10 +946,10 @@ export async function initializeDefaultLlmFields(userId: number): Promise<void>
// ============= SIGNATURES HELPERS =============
export async function getSignaturesByUser(userId: number): Promise<Signature[]> {
export async function getSignaturesByUser(_userId?: number): Promise<Signature[]> {
const db = await getDb();
if (!db) return [];
return db.select().from(signatures).where(eq(signatures.userId, userId));
return db.select().from(signatures);
}
export async function getSignatureById(id: number): Promise<Signature | undefined> {
@@ -762,10 +977,10 @@ export async function deleteSignature(id: number): Promise<void> {
// ============= SERVICE SIGNATURES HELPERS =============
export async function getServiceSignaturesByUser(userId: number): Promise<ServiceSignature[]> {
export async function getServiceSignaturesByUser(_userId?: number): Promise<ServiceSignature[]> {
const db = await getDb();
if (!db) return [];
return db.select().from(serviceSignatures).where(eq(serviceSignatures.userId, userId));
return db.select().from(serviceSignatures);
}
export async function getServiceSignatureByService(userId: number, serviceName: string): Promise<ServiceSignature | undefined> {
@@ -1109,3 +1324,74 @@ export async function updateFreeproLastRun(
.set(update)
.where(eq(freeproSettings.userId, userId));
}
// ============= WEB IMPORT SOURCES =============
/** Génère un token API aléatoire de 64 caractères */
function generateApiToken(): string {
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
let token = '';
for (let i = 0; i < 64; i++) {
token += chars.charAt(Math.floor(Math.random() * chars.length));
}
return token;
}
export async function getWebImportSourcesByUser(userId: number): Promise<WebImportSource[]> {
const db = await getDb();
if (!db) return [];
return db.select().from(webImportSources).where(eq(webImportSources.userId, userId)).orderBy(desc(webImportSources.createdAt));
}
export async function getWebImportSourceById(id: number): Promise<WebImportSource | undefined> {
const db = await getDb();
if (!db) return undefined;
const result = await db.select().from(webImportSources).where(eq(webImportSources.id, id)).limit(1);
return result[0];
}
export async function getWebImportSourceByToken(token: string): Promise<WebImportSource | undefined> {
const db = await getDb();
if (!db) return undefined;
const result = await db.select().from(webImportSources).where(eq(webImportSources.apiToken, token)).limit(1);
return result[0];
}
export async function createWebImportSource(data: Omit<InsertWebImportSource, 'apiToken'>): Promise<WebImportSource> {
const db = await getDb();
if (!db) throw new Error("Database not available");
const apiToken = generateApiToken();
const result = await db.insert(webImportSources).values({ ...data, apiToken });
const insertedId = Number(result[0].insertId);
const inserted = await db.select().from(webImportSources).where(eq(webImportSources.id, insertedId)).limit(1);
return inserted[0]!;
}
export async function updateWebImportSource(id: number, data: Partial<InsertWebImportSource>): Promise<void> {
const db = await getDb();
if (!db) throw new Error("Database not available");
await db.update(webImportSources).set({ ...data, updatedAt: new Date() }).where(eq(webImportSources.id, id));
}
export async function deleteWebImportSource(id: number): Promise<void> {
const db = await getDb();
if (!db) throw new Error("Database not available");
await db.delete(webImportSources).where(eq(webImportSources.id, id));
}
export async function updateWebImportSourceStatus(
id: number,
status: string,
importCount: number,
success: boolean
): Promise<void> {
const db = await getDb();
if (!db) return;
const update: Partial<InsertWebImportSource> = {
lastStatus: status,
lastImportCount: importCount,
updatedAt: new Date(),
};
if (success) update.lastSuccessAt = new Date();
await db.update(webImportSources).set(update).where(eq(webImportSources.id, id));
}

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,21 +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,
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 { 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;
@@ -29,8 +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>();
// 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)
@@ -39,7 +137,8 @@ const activeIntervals = new Map<number, NodeJS.Timeout>();
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}`);
@@ -48,6 +147,21 @@ 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
const sourceFileKey = generateStorageKey(userId, fileName);
@@ -64,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}`);
@@ -92,7 +218,9 @@ async function processEmailAttachment(
mistralApiKey: settings.mistralApiKey,
manusForgeApiKey: settings.manusForgeApiKey,
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'})...`);
@@ -122,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
@@ -171,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,
@@ -232,6 +361,8 @@ async function processEmailAttachment(
duplicateDetails: duplicateDetails.length > 0 ? JSON.stringify(duplicateDetails) : null,
errorDetails: errorDetails.length > 0 ? JSON.stringify(errorDetails) : null,
warningMessage,
importSource: "email",
importTrigger: trigger,
});
console.log(`[EmailImport] Successfully processed attachment: ${fileName}`);
@@ -270,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(
@@ -284,190 +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> {
// 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, box) => {
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: false, // Don't mark as seen yet
});
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) => {
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", () => {
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();
}
}
/**
@@ -475,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";
@@ -552,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,
@@ -575,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." };
}
}
@@ -622,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,
@@ -654,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) {
@@ -663,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,
@@ -191,6 +214,8 @@ async function processFolderFile(
errors: errorsCount,
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

@@ -38,7 +38,6 @@ interface AutoImportResult {
// ── Constantes ─────────────────────────────────────────────────────────────
const FREEPRO_BASE_URL = "https://pro.free.fr";
const LOGIN_URL = `${FREEPRO_BASE_URL}/espace-client/connexion/#/`;
const LOGIN_FORM_URL = `${FREEPRO_BASE_URL}/espace-client/connexion/`;
// Endpoint réel capturé via analyse réseau du portail FreePro (XHR POST)
const DO_LOGIN_URL = `${FREEPRO_BASE_URL}/account/security/do_login`;
@@ -77,17 +76,6 @@ function extractCookies(setCookieHeader: string | null): string {
.join("; ");
}
/**
* Calcule le label du mois précédent (les factures FreePro arrivent en début de mois suivant)
*/
function previousMoisLabel(): string {
const now = new Date();
now.setMonth(now.getMonth() - 1);
const m = String(now.getMonth() + 1).padStart(2, "0");
const y = String(now.getFullYear());
return `${m}/${y}`;
}
// ── Connexion au portail FreePro ───────────────────────────────────────────
/**

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 { invokeLLM, 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");
}
});
});

Some files were not shown because too many files have changed in this diff Show More