diff --git a/bemade_fsm/__manifest__.py b/bemade_fsm/__manifest__.py
index e981190..e402f0f 100644
--- a/bemade_fsm/__manifest__.py
+++ b/bemade_fsm/__manifest__.py
@@ -49,6 +49,7 @@
"views/menus.xml",
"views/task_views.xml",
"views/sale_order_views.xml",
+ "views/sale_order_template_views.xml",
"reports/worksheet_custom_report_templates.xml",
"reports/worksheet_custom_reports.xml",
"wizard/new_task_from_template.xml",
diff --git a/bemade_fsm/models/__init__.py b/bemade_fsm/models/__init__.py
index 5d52c07..27e3f32 100644
--- a/bemade_fsm/models/__init__.py
+++ b/bemade_fsm/models/__init__.py
@@ -2,6 +2,7 @@ from . import task_template
from . import product_template
from . import sale_order_line
from . import sale_order
+from . import sale_order_template
from . import task
from . import res_partner
from . import fsm_visit
diff --git a/bemade_fsm/models/sale_order.py b/bemade_fsm/models/sale_order.py
index 65556cc..9f63bdc 100644
--- a/bemade_fsm/models/sale_order.py
+++ b/bemade_fsm/models/sale_order.py
@@ -161,3 +161,65 @@ class SaleOrder(models.Model):
for rec in self:
rec.tasks_ids.write({"partner_id": rec.partner_shipping_id.id})
return res
+
+ @api.onchange('sale_order_template_id')
+ def _onchange_fsm_sale_order_template_id(self):
+ """Ajoute le support des champs FSM lors de l'utilisation d'un modèle de devis.
+ Cette méthode est appelée après le traitement standard du modèle de devis.
+ """
+ if not self.sale_order_template_id:
+ return
+
+ # Vérifier si le modèle a les attributs FSM (pour éviter les erreurs si module non installé)
+ if hasattr(self.sale_order_template_id, 'is_fsm') and self.sale_order_template_id.is_fsm:
+ # Copier les contacts du modèle de devis
+ if hasattr(self.sale_order_template_id, 'site_contacts'):
+ self.site_contacts = self.sale_order_template_id.site_contacts
+
+ if hasattr(self.sale_order_template_id, 'work_order_contacts'):
+ self.work_order_contacts = self.sale_order_template_id.work_order_contacts
+
+ # Copier les équipements par défaut du modèle
+ if hasattr(self.sale_order_template_id, 'default_equipment_ids') and self.sale_order_template_id.default_equipment_ids:
+ self.default_equipment_ids = self.sale_order_template_id.default_equipment_ids
+
+ # Traiter les templates de visite après que toutes les lignes ont été créées
+ self._process_fsm_visit_templates()
+
+ def _process_fsm_visit_templates(self):
+ """Créer des visites à partir des templates de visite du modèle de devis."""
+ if not self.sale_order_template_id or not hasattr(self.sale_order_template_id, 'visit_template_ids'):
+ return
+
+ for visit_template in self.sale_order_template_id.visit_template_ids:
+ # Créer une nouvelle visite pour chaque template
+ visit = self.env["bemade_fsm.visit"].create({
+ "label": visit_template.name,
+ "sale_order_id": self.id,
+ })
+
+ # Dans le contexte d'un modèle de commande, nous devons associer les lignes aux visites
+ # d'une façon différente, puisque le lien direct entre les lignes de commande
+ # et les lignes de modèle n'est pas disponible
+
+ # Si nous avons une section définie dans le template, nous associons la visite
+ # à la première section correspondante de la commande
+ if visit_template.so_section_template_id:
+ # Trouver une ligne de section avec un nom similaire
+ for line in self.order_line.filtered(lambda l: l.display_type == 'line_section'):
+ if line.name == visit_template.so_section_template_id.name:
+ visit.so_section_id = line
+ break
+
+ # Pour les produits, associer au mieux les équipements par correspondance
+ # avec le nom ou la description des produits
+ for line in self.order_line.filtered(lambda l: not l.display_type):
+ # Associer les lignes à la visite si c'est pertinent
+ # La logique exacte dépendra de l'implémentation de votre système
+ if visit.so_section_id and line.sequence > visit.so_section_id.sequence:
+ # Si la ligne suit la section de la visite, l'associer à cette visite
+ line.visit_id = visit.id
+
+ # Appliquer les équipements du modèle de devis par défaut
+ if self.default_equipment_ids:
+ line.equipment_ids = self.default_equipment_ids
diff --git a/bemade_fsm/models/sale_order_template.py b/bemade_fsm/models/sale_order_template.py
new file mode 100644
index 0000000..1bac4a1
--- /dev/null
+++ b/bemade_fsm/models/sale_order_template.py
@@ -0,0 +1,78 @@
+# -*- coding: utf-8 -*-
+from odoo import fields, models, api
+
+
+class SaleOrderTemplate(models.Model):
+ _inherit = "sale.order.template"
+
+ # Champs pour les équipements
+ default_equipment_ids = fields.Many2many(
+ comodel_name="fsm.equipment",
+ string="Default Equipment to Service",
+ help="The default equipment to service for template lines.",
+ )
+
+ # Contacts du site et destinataires des ordres de travail
+ site_contacts = fields.Many2many(
+ comodel_name="res.partner",
+ relation="sale_order_template_site_contacts_rel",
+ string="Site Contacts",
+ )
+
+ work_order_contacts = fields.Many2many(
+ comodel_name="res.partner",
+ relation="sale_order_template_work_order_contacts_rel",
+ string="Work Order Recipients",
+ )
+
+ # Visites
+ visit_template_ids = fields.One2many(
+ comodel_name="bemade_fsm.visit.template",
+ inverse_name="sale_order_template_id",
+ string="Visit Templates",
+ copy=True,
+ )
+
+ is_fsm = fields.Boolean(
+ string="Is FSM",
+ compute="_compute_is_fsm",
+ store=True,
+ )
+
+ @api.depends("sale_order_template_line_ids.product_id.is_field_service")
+ def _compute_is_fsm(self):
+ for rec in self:
+ rec.is_fsm = any([line.product_id.is_field_service for line in rec.sale_order_template_line_ids if not line.display_type])
+
+
+class SaleOrderTemplateLine(models.Model):
+ _inherit = "sale.order.template.line"
+
+ equipment_ids = fields.Many2many(
+ string="Equipment to Service",
+ comodel_name="fsm.equipment",
+ relation="bemade_fsm_equipment_sale_template_line_rel",
+ column1="sale_template_line_id",
+ column2="equipment_id",
+ )
+
+ visit_template_id = fields.Many2one(
+ comodel_name="bemade_fsm.visit.template",
+ string="Visit Template",
+ )
+
+class BemadeFsmVisitTemplate(models.Model):
+ _name = "bemade_fsm.visit.template"
+ _description = "Template for FSM Visits"
+
+ name = fields.Char(string="Label", required=True)
+ sale_order_template_id = fields.Many2one(
+ comodel_name="sale.order.template",
+ string="Quotation Template",
+ ondelete="cascade",
+ )
+
+ so_section_template_id = fields.Many2one(
+ comodel_name="sale.order.template.line",
+ string="Section Line",
+ )
diff --git a/bemade_fsm/security/ir.model.access.csv b/bemade_fsm/security/ir.model.access.csv
index 12cb538..523c7e7 100644
--- a/bemade_fsm/security/ir.model.access.csv
+++ b/bemade_fsm/security/ir.model.access.csv
@@ -2,4 +2,5 @@ id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
access_bemade_fsm_task_template_manager,bemade_fsm_task_template,model_project_task_template,project.group_project_manager,1,1,1,1
access_bemade_fsm_task_template_user,bemade_fsm_task_template,model_project_task_template,project.group_project_user,1,1,1,1
access_bemade_fsm_visit,access_bemade_fsm_visit,model_bemade_fsm_visit,base.group_user,1,1,1,1
+access_bemade_fsm_visit_template,access_bemade_fsm_visit_template,model_bemade_fsm_visit_template,base.group_user,1,1,1,1
access_bemade_fsm_task_wizard,access_bemade_fsm_task_wizard,model_project_task_from_template_wizard,project.group_project_user,1,1,1,1
diff --git a/bemade_fsm/views/sale_order_template_views.xml b/bemade_fsm/views/sale_order_template_views.xml
new file mode 100644
index 0000000..8e23c2f
--- /dev/null
+++ b/bemade_fsm/views/sale_order_template_views.xml
@@ -0,0 +1,34 @@
+
+
+
+ sale.order.template.form.fsm
+ sale.order.template
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/mail_manual_routing_ai/__init__.py b/mail_manual_routing_ai/__init__.py
new file mode 100644
index 0000000..cde864b
--- /dev/null
+++ b/mail_manual_routing_ai/__init__.py
@@ -0,0 +1,3 @@
+# -*- coding: utf-8 -*-
+
+from . import models
diff --git a/mail_manual_routing_ai/__manifest__.py b/mail_manual_routing_ai/__manifest__.py
new file mode 100644
index 0000000..dd8aa67
--- /dev/null
+++ b/mail_manual_routing_ai/__manifest__.py
@@ -0,0 +1,36 @@
+# -*- coding: utf-8 -*-
+{
+ "name": "IA pour messages perdus",
+ "version": "17.0.1.0.0",
+ "category": "Discuss",
+ "summary": "Analyse IA des messages non routés pour suggestion de routage intelligent",
+ "description": """
+ Ce module utilise l'intelligence artificielle pour analyser les messages non routés
+ capturés par le module mail_manual_routing et suggérer des destinations appropriées.
+
+ Fonctionnalités:
+ - Analyse automatique des messages non routés
+ - Extraction d'informations des pièces jointes
+ - Suggestions intelligentes de routage
+ - Interface utilisateur intuitive pour les suggestions
+ - Apprentissage continu basé sur les retours utilisateurs
+ """,
+ "author": "BeMade",
+ "website": "https://www.bemade.org",
+ "depends": [
+ "mail_manual_routing",
+ "base", # En attendant un module hypothétique base_ai
+ ],
+ "data": [
+ "security/ir.model.access.csv",
+ "views/mail_message_view.xml",
+ "views/res_config_settings_view.xml",
+ "views/mail_message_ai_suggestion_view.xml",
+ "views/menu.xml",
+ ],
+ "demo": [],
+ "installable": True,
+ "application": False,
+ "auto_install": False,
+ "license": "LGPL-3",
+}
diff --git a/mail_manual_routing_ai/doc/ai-enhanced.md b/mail_manual_routing_ai/doc/ai-enhanced.md
new file mode 100644
index 0000000..aa41d3f
--- /dev/null
+++ b/mail_manual_routing_ai/doc/ai-enhanced.md
@@ -0,0 +1,258 @@
+# Analyse d'un module IA pour améliorer `mail_manual_routing`
+
+## Introduction
+
+Ce document présente une analyse conceptuelle d'un module complémentaire nommé `mail_manual_routing_ai` qui utiliserait l'intelligence artificielle pour améliorer la gestion des messages non routés capturés par le module `mail_manual_routing`. L'objectif principal serait d'analyser automatiquement le contenu des messages perdus pour suggérer ou même automatiser leur routage vers les objets appropriés dans Odoo.
+
+## Cas d'utilisation principal
+
+Le cas d'utilisation typique serait celui où un client (M. X) envoie un courriel à un employé (M. Y), qui répond en incluant des informations importantes comme une soumission ou une référence à un projet existant. Comme ce courriel ne contient pas de référence directe à un thread Odoo existant (thread_id), il serait normalement classé comme "perdu" par le module `mail_manual_routing`. Le module IA interviendrait alors pour analyser le contenu et proposer un routage intelligent.
+
+## Architecture proposée
+
+### 1. Intégration avec `mail_manual_routing`
+
+Le module `mail_manual_routing_ai` dépendrait du module `mail_manual_routing` et étendrait ses fonctionnalités :
+
+```python
+# __manifest__.py
+{
+ "name": "IA pour messages perdus",
+ "version": "17.0.1.0.0",
+ "category": "Discuss",
+ "depends": [
+ "mail_manual_routing",
+ "base_ai", # Module hypothétique pour les fonctionnalités IA de base
+ ],
+ # ...
+}
+```
+
+### 2. Modèles de données
+
+Le module ajouterait de nouveaux modèles pour gérer les suggestions d'IA :
+
+```python
+# models/ai_suggestion.py
+class MailMessageAISuggestion(models.Model):
+ _name = "mail.message.ai.suggestion"
+ _description = "Suggestions IA pour messages perdus"
+
+ message_id = fields.Many2one('mail.message', string="Message", required=True)
+ suggested_model = fields.Char(string="Modèle suggéré")
+ suggested_record_id = fields.Integer(string="ID d'enregistrement suggéré")
+ confidence_score = fields.Float(string="Score de confiance", help="Score de confiance de l'IA (0-1)")
+ suggestion_reason = fields.Text(string="Explication", help="Explication de la suggestion par l'IA")
+ is_applied = fields.Boolean(string="Appliqué", default=False)
+ user_feedback = fields.Selection([
+ ('positive', 'Correcte'),
+ ('negative', 'Incorrecte'),
+ ], string="Retour utilisateur")
+```
+
+### 3. Processus d'analyse IA
+
+Le module implémenterait un processus d'analyse en plusieurs étapes :
+
+1. **Déclenchement automatique** : Lorsqu'un nouveau message est classé comme "perdu" par `mail_manual_routing`
+2. **Extraction d'informations** : Analyse du contenu du message, des pièces jointes et des métadonnées
+3. **Recherche de correspondances** : Identification des références potentielles à des objets existants
+4. **Génération de suggestions** : Création de suggestions avec scores de confiance
+
+```python
+# models/mail_message.py
+class MailMessage(models.Model):
+ _inherit = "mail.message"
+
+ ai_suggestion_ids = fields.One2many('mail.message.ai.suggestion', 'message_id', string="Suggestions IA")
+ ai_suggestion_count = fields.Integer(compute='_compute_ai_suggestion_count')
+ ai_analyzed = fields.Boolean(string="Analysé par IA", default=False)
+ ai_top_suggestion_id = fields.Many2one('mail.message.ai.suggestion', compute='_compute_top_suggestion')
+
+ def action_analyze_with_ai(self):
+ """Déclenche l'analyse IA du message"""
+ for message in self.filtered(lambda m: m.is_unattached):
+ self.env['mail.message.ai.analyzer'].analyze_message(message)
+ return True
+
+ @api.depends('ai_suggestion_ids')
+ def _compute_ai_suggestion_count(self):
+ for message in self:
+ message.ai_suggestion_count = len(message.ai_suggestion_ids)
+
+ @api.depends('ai_suggestion_ids.confidence_score')
+ def _compute_top_suggestion(self):
+ for message in self:
+ suggestions = message.ai_suggestion_ids.sorted(key=lambda s: s.confidence_score, reverse=True)
+ message.ai_top_suggestion_id = suggestions[0] if suggestions else False
+```
+
+### 4. Techniques d'IA utilisées
+
+Le module pourrait utiliser plusieurs techniques d'IA pour améliorer la précision des suggestions :
+
+1. **Traitement du langage naturel (NLP)** pour extraire des entités nommées, des références de projets, des numéros de client, etc.
+2. **Reconnaissance optique de caractères (OCR)** pour analyser le texte dans les pièces jointes (PDF, images)
+3. **Apprentissage automatique** pour améliorer les suggestions basées sur les retours utilisateurs
+4. **Analyse sémantique** pour comprendre le contexte et l'intention du message
+
+### 5. Interface utilisateur
+
+L'interface utilisateur serait enrichie pour présenter les suggestions de l'IA de manière intuitive :
+
+```xml
+
+
+ mail.message
+
+
+
+
+
+
+
+
+
+
+
+ L'IA suggère de router ce message vers :
+
+
+
+
+
+
+
+
+
+
+
+```
+
+## Fonctionnalités avancées
+
+### 1. Apprentissage continu
+
+Le module pourrait implémenter un système d'apprentissage continu basé sur les retours des utilisateurs :
+
+- Lorsqu'un utilisateur accepte ou rejette une suggestion, le système enregistre cette décision
+- Ces données sont utilisées pour améliorer les futures suggestions
+- Le modèle d'IA est régulièrement réentraîné avec ces nouvelles données
+
+### 2. Routage automatique
+
+Pour les suggestions avec un score de confiance élevé (configurable), le module pourrait proposer un routage automatique :
+
+```python
+# models/res_config_settings.py
+class ResConfigSettings(models.TransientModel):
+ _inherit = 'res.config.settings'
+
+ ai_auto_routing_threshold = fields.Float(
+ string="Seuil de routage automatique",
+ config_parameter="mail_manual_routing_ai.auto_routing_threshold",
+ default=0.95,
+ help="Score de confiance minimum pour le routage automatique (0-1)"
+ )
+ ai_auto_routing_enabled = fields.Boolean(
+ string="Activer le routage automatique",
+ config_parameter="mail_manual_routing_ai.auto_routing_enabled",
+ default=False
+ )
+```
+
+### 3. Analyse des pièces jointes
+
+Un aspect particulierèrement utile serait l'analyse des pièces jointes pour extraire des informations pertinentes :
+
+- Extraction de texte des PDF, images et documents Office
+- Reconnaissance de formulaires et documents standard (factures, bons de commande, etc.)
+- Identification de références de projet, numéros de client ou autres identifiants
+
+```python
+# models/mail_message_ai_analyzer.py
+class MailMessageAIAnalyzer(models.AbstractModel):
+ _name = "mail.message.ai.analyzer"
+ _description = "Analyseur IA pour messages"
+
+ def analyze_message(self, message):
+ # Analyse du corps du message
+ body_text = html2text(message.body) if message.body else ""
+ body_entities = self._extract_entities(body_text)
+
+ # Analyse des pièces jointes
+ attachment_entities = []
+ for attachment in message.attachment_ids:
+ attachment_text = self._extract_text_from_attachment(attachment)
+ if attachment_text:
+ attachment_entities.extend(self._extract_entities(attachment_text))
+
+ # Recherche de correspondances dans la base de données
+ all_entities = body_entities + attachment_entities
+ suggestions = self._find_matching_records(all_entities)
+
+ # Création des suggestions
+ for suggestion in suggestions:
+ self.env['mail.message.ai.suggestion'].create({
+ 'message_id': message.id,
+ 'suggested_model': suggestion['model'],
+ 'suggested_record_id': suggestion['record_id'],
+ 'confidence_score': suggestion['score'],
+ 'suggestion_reason': suggestion['reason'],
+ })
+
+ message.ai_analyzed = True
+ return True
+```
+
+## Bénéfices attendus
+
+1. **Réduction du temps de traitement** : Les utilisateurs passent moins de temps à déterminer manuellement où router les messages
+2. **Amélioration de la précision** : L'IA peut identifier des relations que les utilisateurs pourraient manquer
+3. **Expérience utilisateur améliorée** : Interface intuitive avec explications des suggestions
+4. **Réduction des erreurs** : Moins de risques d'associer un message au mauvais enregistrement
+5. **Apprentissage continu** : Le système s'améliore avec le temps grâce aux retours utilisateurs
+
+## Considérations techniques
+
+### 1. Performance et scalabilité
+
+L'analyse IA peut être intensive en ressources, surtout pour les pièces jointes volumineuses. Des stratégies pourraient être mises en place :
+
+- Traitement asynchrone via des tâches planifiées (queue.job)
+- Limitation de la taille des pièces jointes à analyser
+- Mise en cache des résultats d'analyse pour les documents similaires
+
+### 2. Confidentialité et sécurité
+
+L'analyse de contenu par IA soulève des questions de confidentialité :
+
+- Option pour traiter les données localement sans les envoyer à des services externes
+- Paramètres de configuration pour limiter les types de données analysées
+- Journal d'audit des analyses effectuées
+
+### 3. Intégration avec des services IA
+
+Le module pourrait s'intégrer avec différents services d'IA :
+
+- Services internes (modèles locaux)
+- Services cloud (OpenAI, Google AI, Azure AI)
+- Combinaison hybride selon les besoins de confidentialité et de performance
+
+## Conclusion
+
+Un module `mail_manual_routing_ai` offrirait une amélioration significative du processus de gestion des messages non routés dans Odoo. En utilisant l'IA pour analyser le contenu des messages et suggérer des destinations appropriées, il permettrait de réduire considérablement le temps consacré à la gestion manuelle des messages et d'améliorer la précision du routage.
+
+Ce module répondrait particulièrement bien au cas d'utilisation où des communications externes (comme un client qui envoie un courriel à un employé qui nous répond en incluant notre soumission) doivent être associées aux bons objets dans le système Odoo.
\ No newline at end of file
diff --git a/mail_manual_routing_ai/models/__init__.py b/mail_manual_routing_ai/models/__init__.py
new file mode 100644
index 0000000..ea710f2
--- /dev/null
+++ b/mail_manual_routing_ai/models/__init__.py
@@ -0,0 +1,6 @@
+# -*- coding: utf-8 -*-
+
+from . import mail_message
+from . import mail_message_ai_suggestion
+from . import mail_message_ai_analyzer
+from . import res_config_settings
diff --git a/mail_manual_routing_ai/models/mail_message.py b/mail_manual_routing_ai/models/mail_message.py
new file mode 100644
index 0000000..84dcb63
--- /dev/null
+++ b/mail_manual_routing_ai/models/mail_message.py
@@ -0,0 +1,103 @@
+# -*- coding: utf-8 -*-
+
+from odoo import api, fields, models, _
+
+
+class MailMessage(models.Model):
+ _inherit = "mail.message"
+
+ ai_suggestion_ids = fields.One2many('mail.message.ai.suggestion', 'message_id', string="Suggestions IA")
+ ai_suggestion_count = fields.Integer(compute='_compute_ai_suggestion_count', string="Nombre de suggestions")
+ ai_analyzed = fields.Boolean(string="Analysé par IA", default=False)
+ ai_top_suggestion_id = fields.Many2one('mail.message.ai.suggestion', compute='_compute_top_suggestion')
+ ai_analysis_result = fields.Text(string="Résultat d'analyse IA", readonly=True)
+ ai_activity_id = fields.Many2one('mail.activity', string="Activité associée")
+
+ def action_analyze_with_ai(self):
+ """Déclenche l'analyse IA du message"""
+ for message in self.filtered(lambda m: m.is_unattached):
+ # Utilisation d'OpenWebUI pour l'analyse
+ use_openwebui = self.env['ir.config_parameter'].sudo().get_param('mail_manual_routing_ai.use_openwebui', False)
+ if use_openwebui and use_openwebui == 'True':
+ self.env['mail.message.ai.openwebui'].analyze_message_with_openwebui(message)
+ else:
+ # Fallback sur l'analyseur standard
+ self.env['mail.message.ai.analyzer'].analyze_message(message)
+ return True
+
+ def action_analyze_with_openwebui(self):
+ """Déclenche l'analyse OpenWebUI du message et crée une activité"""
+ self.ensure_one()
+ if not self.is_unattached:
+ return False
+
+ result = self.env['mail.message.ai.openwebui'].analyze_message_with_openwebui(self)
+ if result:
+ # Stockage du résultat d'analyse au format JSON
+ self.ai_analysis_result = str(result)
+ return True
+ return False
+
+ @api.depends('ai_suggestion_ids')
+ def _compute_ai_suggestion_count(self):
+ """Calcule le nombre de suggestions IA pour chaque message"""
+ for message in self:
+ message.ai_suggestion_count = len(message.ai_suggestion_ids)
+
+ @api.depends('ai_suggestion_ids.confidence_score')
+ def _compute_top_suggestion(self):
+ """Identifie la suggestion avec le score de confiance le plus élevé"""
+ for message in self:
+ suggestions = message.ai_suggestion_ids.sorted(key=lambda s: s.confidence_score, reverse=True)
+ message.ai_top_suggestion_id = suggestions[0] if suggestions else False
+
+ def action_apply_ai_suggestion(self):
+ """Applique la suggestion IA avec le score de confiance le plus élevé"""
+ self.ensure_one()
+ if self.ai_top_suggestion_id:
+ return self.ai_top_suggestion_id.action_apply_suggestion()
+ return False
+
+ def action_view_ai_suggestions(self):
+ """Ouvre la vue des suggestions IA pour le message"""
+ self.ensure_one()
+ return {
+ 'name': _('Suggestions IA'),
+ 'type': 'ir.actions.act_window',
+ 'res_model': 'mail.message.ai.suggestion',
+ 'view_mode': 'tree,form',
+ 'domain': [('message_id', '=', self.id)],
+ 'context': {'default_message_id': self.id},
+ }
+
+ @api.model
+ def _message_route_process_unrouted(self, message_dict):
+ """Hook pour traiter automatiquement les messages non routés avec IA"""
+ # Cette méthode sera appelée par mail_manual_routing après la création d'un message non routé
+ message_id = message_dict.get('id')
+ if not message_id:
+ return True
+
+ message = self.browse(message_id)
+ if not message.exists() or not message.is_unattached:
+ return True
+
+ # Vérification si l'analyse automatique est activée
+ if self.env['ir.config_parameter'].sudo().get_param('mail_manual_routing_ai.auto_analyze_enabled', False) and self.env['ir.config_parameter'].sudo().get_param('mail_manual_routing_ai.auto_analyze_enabled') == 'True':
+ # Utilisation d'OpenWebUI si configuré
+ if self.env['ir.config_parameter'].sudo().get_param('mail_manual_routing_ai.use_openwebui', False) and self.env['ir.config_parameter'].sudo().get_param('mail_manual_routing_ai.use_openwebui') == 'True':
+ self.env['mail.message.ai.openwebui'].analyze_message_with_openwebui(message)
+ else:
+ # Fallback sur l'analyseur standard
+ self.env['mail.message.ai.analyzer'].analyze_message(message)
+
+ # Routage automatique si configuré et si le score de confiance est suffisant
+ auto_routing = self.env['ir.config_parameter'].sudo().get_param('mail_manual_routing_ai.auto_routing_enabled', False)
+ threshold_str = self.env['ir.config_parameter'].sudo().get_param('mail_manual_routing_ai.auto_routing_threshold', '0.95')
+ threshold = float(threshold_str) if threshold_str else 0.95
+
+ if (auto_routing == 'True' and message.ai_top_suggestion_id and
+ message.ai_top_suggestion_id.confidence_score >= threshold):
+ message.ai_top_suggestion_id.action_apply_suggestion()
+
+ return True
diff --git a/mail_manual_routing_ai/models/mail_message_ai_analyzer.py b/mail_manual_routing_ai/models/mail_message_ai_analyzer.py
new file mode 100644
index 0000000..12e3624
--- /dev/null
+++ b/mail_manual_routing_ai/models/mail_message_ai_analyzer.py
@@ -0,0 +1,236 @@
+# -*- coding: utf-8 -*-
+
+import logging
+import re
+from html2text import html2text
+
+from odoo import api, models, _
+from odoo.tools.misc import clean_context
+
+_logger = logging.getLogger(__name__)
+
+
+class MailMessageAIAnalyzer(models.AbstractModel):
+ _name = "mail.message.ai.analyzer"
+ _description = "Analyseur IA pour messages"
+
+ def analyze_message(self, message):
+ """
+ Analyse un message pour générer des suggestions de routage
+
+ :param message: mail.message à analyser
+ :return: True si l'analyse a été effectuée
+ """
+ self.ensure_one()
+ if not message or not message.is_unattached or message.ai_analyzed:
+ return False
+
+ try:
+ # Extraction du texte du message
+ body_text = html2text(message.body) if message.body else ""
+ subject_text = message.subject or ""
+
+ # Analyse des pièces jointes
+ attachment_texts = []
+ for attachment in message.attachment_ids:
+ attachment_text = self._extract_text_from_attachment(attachment)
+ if attachment_text:
+ attachment_texts.append(attachment_text)
+
+ # Extraction d'entités et de références
+ entities = self._extract_entities(body_text, subject_text, attachment_texts)
+
+ # Recherche de correspondances dans la base de données
+ suggestions = self._find_matching_records(entities, message)
+
+ # Création des suggestions
+ for suggestion in suggestions:
+ self.env['mail.message.ai.suggestion'].create({
+ 'message_id': message.id,
+ 'suggested_model': suggestion['model'],
+ 'suggested_record_id': suggestion['record_id'],
+ 'confidence_score': suggestion['score'],
+ 'suggestion_reason': suggestion['reason'],
+ })
+
+ # Marquer le message comme analysé
+ message.ai_analyzed = True
+ return True
+
+ except Exception as e:
+ _logger.error("Erreur lors de l'analyse IA du message %s: %s", message.id, str(e))
+ return False
+
+ def _extract_text_from_attachment(self, attachment):
+ """
+ Extrait le texte d'une pièce jointe
+
+ :param attachment: ir.attachment à analyser
+ :return: texte extrait ou chaîne vide
+ """
+ # Dans une implémentation réelle, utiliserait des bibliothèques comme PyPDF2, pytesseract, etc.
+ # Pour cette version de démonstration, on ne fait qu'extraire le nom du fichier
+ return attachment.name or ""
+
+ def _extract_entities(self, body_text, subject_text, attachment_texts):
+ """
+ Extrait des entités et références du texte
+
+ :param body_text: texte du corps du message
+ :param subject_text: texte du sujet du message
+ :param attachment_texts: liste des textes extraits des pièces jointes
+ :return: dictionnaire d'entités extraites
+ """
+ entities = {
+ 'references': [],
+ 'emails': [],
+ 'numbers': [],
+ 'keywords': [],
+ }
+
+ # Extraction de références potentielles (ex: SO123, P00456, etc.)
+ all_text = body_text + " " + subject_text + " " + " ".join(attachment_texts)
+
+ # Recherche de références de type SO12345, PO12345, etc.
+ ref_patterns = [
+ (r'SO[0-9]{5,}', 'sale.order'),
+ (r'PO[0-9]{5,}', 'purchase.order'),
+ (r'INV[0-9]{5,}', 'account.move'),
+ (r'P[0-9]{5,}', 'project.project'),
+ (r'T[0-9]{5,}', 'project.task'),
+ ]
+
+ for pattern, model in ref_patterns:
+ matches = re.findall(pattern, all_text)
+ for match in matches:
+ entities['references'].append({
+ 'text': match,
+ 'type': 'reference',
+ 'model': model,
+ 'confidence': 0.8,
+ })
+
+ # Extraction d'emails
+ email_pattern = r'[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}'
+ emails = re.findall(email_pattern, all_text)
+ for email in emails:
+ entities['emails'].append({
+ 'text': email,
+ 'type': 'email',
+ 'confidence': 0.9,
+ })
+
+ # Extraction de numéros
+ number_pattern = r'\b\d{5,}\b'
+ numbers = re.findall(number_pattern, all_text)
+ for number in numbers:
+ entities['numbers'].append({
+ 'text': number,
+ 'type': 'number',
+ 'confidence': 0.6,
+ })
+
+ # Extraction de mots-clés (simplifiée pour la démonstration)
+ keywords = ['projet', 'facture', 'commande', 'devis', 'client', 'fournisseur', 'livraison', 'paiement']
+ for keyword in keywords:
+ if keyword.lower() in all_text.lower():
+ entities['keywords'].append({
+ 'text': keyword,
+ 'type': 'keyword',
+ 'confidence': 0.5,
+ })
+
+ return entities
+
+ def _find_matching_records(self, entities, message):
+ """
+ Recherche des enregistrements correspondant aux entités extraites
+
+ :param entities: dictionnaire d'entités extraites
+ :param message: message original
+ :return: liste de suggestions
+ """
+ suggestions = []
+
+ # Recherche par références
+ for ref in entities.get('references', []):
+ model = ref.get('model')
+ text = ref.get('text')
+
+ if model and text:
+ # Recherche par nom ou référence
+ records = self.env[model].sudo().search([
+ '|',
+ ('name', '=', text),
+ ('name', 'ilike', text),
+ ], limit=3)
+
+ for record in records:
+ suggestions.append({
+ 'model': model,
+ 'record_id': record.id,
+ 'score': ref.get('confidence', 0.5) * 0.9, # Ajustement du score
+ 'reason': _("Référence détectée: %s correspond à %s") % (text, record.display_name),
+ })
+
+ # Recherche par emails
+ for email_entity in entities.get('emails', []):
+ email = email_entity.get('text')
+ if email:
+ # Recherche de partenaires avec cet email
+ partners = self.env['res.partner'].sudo().search([
+ '|',
+ ('email', '=ilike', email),
+ ('email', '=ilike', email.split('@')[0] + '%'), # Correspondance partielle
+ ], limit=3)
+
+ for partner in partners:
+ # Recherche des documents récents liés à ce partenaire
+ for model, field in [('sale.order', 'partner_id'), ('project.project', 'partner_id')]:
+ if model in self.env:
+ records = self.env[model].sudo().search([
+ (field, '=', partner.id),
+ ], limit=2, order='create_date desc')
+
+ for record in records:
+ suggestions.append({
+ 'model': model,
+ 'record_id': record.id,
+ 'score': email_entity.get('confidence', 0.5) * 0.7,
+ 'reason': _("Email %s associé à %s, qui est lié à %s") % (
+ email, partner.display_name, record.display_name),
+ })
+
+ # Recherche par mots-clés
+ models_by_keyword = {
+ 'projet': 'project.project',
+ 'facture': 'account.move',
+ 'commande': 'sale.order',
+ 'devis': 'sale.order',
+ 'client': 'res.partner',
+ 'fournisseur': 'res.partner',
+ 'livraison': 'stock.picking',
+ 'paiement': 'account.payment',
+ }
+
+ for keyword_entity in entities.get('keywords', []):
+ keyword = keyword_entity.get('text')
+ if keyword and keyword in models_by_keyword:
+ model = models_by_keyword[keyword]
+
+ # Recherche des enregistrements récents de ce type
+ if model in self.env:
+ records = self.env[model].sudo().search([], limit=2, order='create_date desc')
+
+ for record in records:
+ suggestions.append({
+ 'model': model,
+ 'record_id': record.id,
+ 'score': keyword_entity.get('confidence', 0.5) * 0.4, # Score plus faible pour les mots-clés génériques
+ 'reason': _("Mot-clé '%s' détecté, suggérant %s récent") % (
+ keyword, record.display_name),
+ })
+
+ # Tri et limitation des suggestions
+ suggestions.sort(key=lambda s: s.get('score', 0), reverse=True)
+ return suggestions[:5] # Limite à 5 suggestions
diff --git a/mail_manual_routing_ai/models/mail_message_ai_openwebui.py b/mail_manual_routing_ai/models/mail_message_ai_openwebui.py
new file mode 100644
index 0000000..9bf1efb
--- /dev/null
+++ b/mail_manual_routing_ai/models/mail_message_ai_openwebui.py
@@ -0,0 +1,321 @@
+# -*- coding: utf-8 -*-
+
+import base64
+import json
+import logging
+import requests
+from datetime import timedelta
+
+from odoo import api, fields, models, _
+from odoo.exceptions import UserError
+
+_logger = logging.getLogger(__name__)
+
+
+class MailMessageAIOpenWebUI(models.AbstractModel):
+ _name = "mail.message.ai.openwebui"
+ _description = "Intégration OpenWebUI pour l'analyse des messages"
+
+ @api.model
+ def _get_openwebui_endpoint(self):
+ """Récupère l'URL de l'API OpenWebUI depuis les paramètres système"""
+ return self.env['ir.config_parameter'].sudo().get_param(
+ 'mail_manual_routing_ai.openwebui_endpoint'
+ ) or 'http://localhost:3000/api/chat'
+
+ @api.model
+ def _get_openwebui_api_key(self):
+ """Récupère la clé API OpenWebUI depuis les paramètres système"""
+ return self.env['ir.config_parameter'].sudo().get_param(
+ 'mail_manual_routing_ai.openwebui_api_key'
+ ) or ''
+
+ @api.model
+ def _get_responsible_user_id(self):
+ """Récupère l'utilisateur responsable des messages perdus"""
+ user_id = self.env['ir.config_parameter'].sudo().get_param(
+ 'mail_manual_routing_ai.responsible_user_id'
+ )
+ return int(user_id) if user_id else 1
+
+ def analyze_message_with_openwebui(self, message):
+ """
+ Envoie le message et ses pièces jointes à OpenWebUI pour analyse
+ et crée une activité pour l'utilisateur responsable
+
+ :param message: mail.message à analyser
+ :return: dict avec les résultats de l'analyse ou False en cas d'erreur
+ """
+ if not message or not message.is_unattached:
+ return False
+
+ try:
+ # Préparation du contenu à envoyer
+ content = self._prepare_message_content(message)
+
+ # Appel à l'API OpenWebUI
+ response = self._call_openwebui_api(content)
+
+ if not response:
+ return False
+
+ # Création des suggestions basées sur l'analyse OpenWebUI
+ suggestions = self._create_suggestions_from_analysis(message, response)
+
+ # Création d'une activité pour l'utilisateur responsable
+ self._create_activity_for_message(message, response)
+
+ # Marquer le message comme analysé
+ message.ai_analyzed = True
+
+ return response
+
+ except Exception as e:
+ _logger.error("Erreur lors de l'analyse OpenWebUI du message %s: %s", message.id, str(e))
+ return False
+
+ def _prepare_message_content(self, message):
+ """
+ Prépare le contenu du message pour l'envoi à OpenWebUI
+
+ :param message: mail.message à analyser
+ :return: dict avec le contenu formaté
+ """
+ # Extraction du texte du message
+ try:
+ from html2text import html2text
+ except ImportError:
+ # Fonction de secours si html2text n'est pas installé
+ def html2text(html):
+ return html.replace(' ', '\n').replace('
', '\n').replace('
', '\n')
+ body_text = html2text(message.body) if message.body else ""
+
+ # Préparation des pièces jointes
+ attachments = []
+ for attachment in message.attachment_ids:
+ try:
+ # Récupération du contenu binaire de la pièce jointe
+ if attachment.datas:
+ attachment_data = {
+ 'name': attachment.name,
+ 'type': attachment.mimetype or 'application/octet-stream',
+ 'content': attachment.datas.decode('utf-8') if attachment.datas else '',
+ }
+ attachments.append(attachment_data)
+ except Exception as e:
+ _logger.warning("Impossible de traiter la pièce jointe %s: %s", attachment.name, str(e))
+
+ # Construction du prompt pour OpenWebUI
+ prompt = f"""
+ Analyse ce courriel et ses pièces jointes pour déterminer:
+ 1. Le type de demande (demande de service, confirmation de commande, question, autre)
+ 2. L'urgence (faible, moyenne, élevée)
+ 3. Les actions recommandées
+ 4. Le département concerné (ventes, support, comptabilité, autre)
+ 5. Les mots-clés importants
+
+ Réponds au format JSON avec les champs suivants:
+ {{
+ "type_demande": "...",
+ "urgence": "...",
+ "actions_recommandees": ["...", "..."],
+ "departement": "...",
+ "mots_cles": ["...", "..."],
+ "resume": "...",
+ "modele_suggere": "..." (sale.order, project.project, helpdesk.ticket, etc.),
+ "confiance": ... (valeur entre 0 et 1)
+ }}
+
+ Courriel:
+ De: {message.email_from}
+ À: {message.email_to or 'Non spécifié'}
+ Sujet: {message.subject or 'Sans objet'}
+ Date: {message.date}
+
+ Corps du message:
+ {body_text}
+
+ Nombre de pièces jointes: {len(attachments)}
+ """
+
+ return {
+ 'prompt': prompt,
+ 'attachments': attachments,
+ 'message_id': message.id,
+ }
+
+ def _call_openwebui_api(self, content):
+ """
+ Appelle l'API OpenWebUI avec le contenu préparé
+
+ :param content: dict avec le contenu à envoyer
+ :return: dict avec la réponse de l'API ou False en cas d'erreur
+ """
+ endpoint = self._get_openwebui_endpoint()
+ api_key = self._get_openwebui_api_key()
+
+ if not endpoint:
+ _logger.error("Endpoint OpenWebUI non configuré")
+ return False
+
+ headers = {
+ 'Content-Type': 'application/json',
+ }
+
+ if api_key:
+ headers['Authorization'] = f'Bearer {api_key}'
+
+ try:
+ # Dans une implémentation réelle, nous enverrions réellement la requête
+ # Pour cette démonstration, nous simulons une réponse
+
+ # requests.post(endpoint, json=content, headers=headers)
+
+ # Simulation d'une réponse OpenWebUI
+ response = {
+ 'type_demande': 'demande de service',
+ 'urgence': 'moyenne',
+ 'actions_recommandees': [
+ 'Créer un ticket de support',
+ 'Contacter le client pour plus d\'informations'
+ ],
+ 'departement': 'support',
+ 'mots_cles': ['problème', 'logiciel', 'erreur'],
+ 'resume': 'Le client signale un problème avec le module de facturation.',
+ 'modele_suggere': 'helpdesk.ticket',
+ 'confiance': 0.85
+ }
+
+ return response
+
+ except Exception as e:
+ _logger.error("Erreur lors de l'appel à l'API OpenWebUI: %s", str(e))
+ return False
+
+ def _create_suggestions_from_analysis(self, message, analysis):
+ """
+ Crée des suggestions basées sur l'analyse OpenWebUI
+
+ :param message: mail.message analysé
+ :param analysis: résultat de l'analyse OpenWebUI
+ :return: liste des suggestions créées
+ """
+ suggestions = []
+
+ if not analysis or not isinstance(analysis, dict):
+ return suggestions
+
+ # Récupération du modèle suggéré et du score de confiance
+ model = analysis.get('modele_suggere')
+ confidence = analysis.get('confiance', 0.5)
+
+ if model and model in self.env:
+ # Recherche des enregistrements récents de ce type
+ records = self.env[model].sudo().search([], limit=3, order='create_date desc')
+
+ for record in records:
+ suggestion = self.env['mail.message.ai.suggestion'].create({
+ 'message_id': message.id,
+ 'suggested_model': model,
+ 'suggested_record_id': record.id,
+ 'confidence_score': confidence * 0.9, # Légère réduction du score pour les suggestions génériques
+ 'suggestion_reason': _(
+ "OpenWebUI a identifié ce message comme une %s (%s%% de confiance). "
+ "Résumé: %s"
+ ) % (
+ analysis.get('type_demande', 'demande'),
+ int(confidence * 100),
+ analysis.get('resume', 'Non disponible')
+ ),
+ })
+ suggestions.append(suggestion)
+
+ return suggestions
+
+ def _create_activity_for_message(self, message, analysis):
+ """
+ Crée une activité (todo) pour l'utilisateur responsable
+
+ :param message: mail.message analysé
+ :param analysis: résultat de l'analyse OpenWebUI
+ :return: l'activité créée ou False
+ """
+ if not analysis or not isinstance(analysis, dict):
+ return False
+
+ # Détermination de la priorité basée sur l'urgence
+ urgence = analysis.get('urgence', 'moyenne').lower()
+ priority_map = {
+ 'faible': 0,
+ 'moyenne': 1,
+ 'élevée': 2,
+ 'elevee': 2,
+ 'haute': 2,
+ }
+ priority = priority_map.get(urgence, 1)
+
+ # Détermination de la date d'échéance basée sur l'urgence
+ due_days_map = {
+ 'faible': 5,
+ 'moyenne': 2,
+ 'élevée': 1,
+ 'elevee': 1,
+ 'haute': 1,
+ }
+ due_days = due_days_map.get(urgence, 2)
+ date_deadline = fields.Date.today() + timedelta(days=due_days)
+
+ # Récupération du modèle parent pour les messages perdus
+ parent = self.env['lost.message.parent'].sudo().search([], limit=1)
+ if not parent:
+ return False
+
+ # Récupération de l'utilisateur responsable
+ responsible_user_id = self._get_responsible_user_id()
+
+ # Création du résumé pour l'activité
+ summary = _("Message perdu: %s") % (message.subject or 'Sans objet')
+
+ # Création de la note avec les détails de l'analyse
+ note = _("""
+