From 416323237194f84308369ae113928988d6dd0bb2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20V=C3=A9zina?= Date: Sat, 16 Nov 2024 06:45:41 -0500 Subject: [PATCH] new modules openai --- customer_itch_cycle/__init__.py | 2 + customer_itch_cycle/__manifest__.py | 15 ++ customer_itch_cycle/models/__init__.py | 3 + .../models/itch_cycle_product_partner.py | 33 ++++ customer_itch_cycle/models/sale_order.py | 44 ++++++ .../views/itch_cycle_product_partner_view.xml | 44 ++++++ .../views/res_partner_view.xml | 23 +++ openai_connector/__init__.py | 1 + openai_connector/__manifest__.py | 22 +++ openai_connector/models/__init__.py | 2 + openai_connector/models/res_company.py | 9 ++ .../models/res_config_settings.py | 96 ++++++++++++ openai_connector/security/ir.model.access.csv | 2 + .../views/res_config_settings_views.xml | 46 ++++++ openai_partner_purchase_analysis/__init__.py | 2 + .../__manifest__.py | 31 ++++ .../data/queue_job_group.xml | 7 + .../models/__init__.py | 4 + .../partner_purchase_analysis_wizard.py | 93 ++++++++++++ .../models/res_company.py | 11 ++ .../models/res_config_settings.py | 36 +++++ .../models/res_partner.py | 20 +++ .../security/ir.model.access.csv | 2 + .../views/res_config_settings_view.xml | 19 +++ .../views/res_partner_view.xml | 17 +++ .../wizard/__init__.py | 1 + .../partner_purchase_analysis_wizard.py | 141 ++++++++++++++++++ .../partner_purchase_analysis_wizard_view.xml | 45 ++++++ 28 files changed, 771 insertions(+) create mode 100644 customer_itch_cycle/__init__.py create mode 100644 customer_itch_cycle/__manifest__.py create mode 100644 customer_itch_cycle/models/__init__.py create mode 100644 customer_itch_cycle/models/itch_cycle_product_partner.py create mode 100644 customer_itch_cycle/models/sale_order.py create mode 100644 customer_itch_cycle/views/itch_cycle_product_partner_view.xml create mode 100644 customer_itch_cycle/views/res_partner_view.xml create mode 100644 openai_connector/__init__.py create mode 100644 openai_connector/__manifest__.py create mode 100644 openai_connector/models/__init__.py create mode 100644 openai_connector/models/res_company.py create mode 100644 openai_connector/models/res_config_settings.py create mode 100644 openai_connector/security/ir.model.access.csv create mode 100644 openai_connector/views/res_config_settings_views.xml create mode 100644 openai_partner_purchase_analysis/__init__.py create mode 100644 openai_partner_purchase_analysis/__manifest__.py create mode 100644 openai_partner_purchase_analysis/data/queue_job_group.xml create mode 100644 openai_partner_purchase_analysis/models/__init__.py create mode 100644 openai_partner_purchase_analysis/models/partner_purchase_analysis_wizard.py create mode 100644 openai_partner_purchase_analysis/models/res_company.py create mode 100644 openai_partner_purchase_analysis/models/res_config_settings.py create mode 100644 openai_partner_purchase_analysis/models/res_partner.py create mode 100644 openai_partner_purchase_analysis/security/ir.model.access.csv create mode 100644 openai_partner_purchase_analysis/views/res_config_settings_view.xml create mode 100644 openai_partner_purchase_analysis/views/res_partner_view.xml create mode 100644 openai_partner_purchase_analysis/wizard/__init__.py create mode 100644 openai_partner_purchase_analysis/wizard/partner_purchase_analysis_wizard.py create mode 100644 openai_partner_purchase_analysis/wizard/partner_purchase_analysis_wizard_view.xml diff --git a/customer_itch_cycle/__init__.py b/customer_itch_cycle/__init__.py new file mode 100644 index 0000000..a9e3372 --- /dev/null +++ b/customer_itch_cycle/__init__.py @@ -0,0 +1,2 @@ + +from . import models diff --git a/customer_itch_cycle/__manifest__.py b/customer_itch_cycle/__manifest__.py new file mode 100644 index 0000000..f8417fe --- /dev/null +++ b/customer_itch_cycle/__manifest__.py @@ -0,0 +1,15 @@ + +{ + 'name': 'Customer Itch Cycle Management', + 'version': '1.0', + 'depends': ['base', 'sale'], + 'author': 'Your Name', + 'category': 'Sales Management', + 'description': "Manage customer itch cycles by product for proactive sales engagement.", + 'data': [ + 'views/itch_cycle_product_partner_view.xml', + 'views/res_partner_view.xml', + ], + 'installable': True, + 'application': False, +} diff --git a/customer_itch_cycle/models/__init__.py b/customer_itch_cycle/models/__init__.py new file mode 100644 index 0000000..8272bc5 --- /dev/null +++ b/customer_itch_cycle/models/__init__.py @@ -0,0 +1,3 @@ + +from . import itch_cycle_product_partner +from . import sale_order diff --git a/customer_itch_cycle/models/itch_cycle_product_partner.py b/customer_itch_cycle/models/itch_cycle_product_partner.py new file mode 100644 index 0000000..f5dbbfe --- /dev/null +++ b/customer_itch_cycle/models/itch_cycle_product_partner.py @@ -0,0 +1,33 @@ + +from odoo import models, fields, api +from datetime import timedelta, datetime + +class ItchCycleProductPartner(models.Model): + _name = 'itch_cycle_product_partner' + _description = 'Itch Cycle by Product and Partner' + + partner_id = fields.Many2one('res.partner', string="Client", required=True, ondelete='cascade') + product_id = fields.Many2one('product.product', string="Produit", required=True) + last_purchase_date = fields.Date(string="Dernière Date d'Achat") + itch_cycle_duration = fields.Integer(string="Durée du Itch-Cycle (jours)", default=0) + next_follow_up_date = fields.Date(string="Prochaine Date de Suivi", compute="_compute_next_follow_up_date", store=True) + + @api.depends('last_purchase_date', 'itch_cycle_duration') + def _compute_next_follow_up_date(self): + for record in self: + if record.last_purchase_date and record.itch_cycle_duration > 0: + record.next_follow_up_date = record.last_purchase_date + timedelta(days=record.itch_cycle_duration) + else: + record.next_follow_up_date = False + +class ResPartner(models.Model): + _inherit = 'res.partner' + + itch_cycle_product_ids = fields.One2many('itch_cycle_product_partner', 'partner_id', string="Itch Cycles Produits") + itch_next_delay = fields.Date(string="Prochaine Date de Suivi (Itch-Cycle Min)", compute="_compute_itch_next_delay", store=True) + + @api.depends('itch_cycle_product_ids.next_follow_up_date') + def _compute_itch_next_delay(self): + for partner in self: + follow_up_dates = partner.itch_cycle_product_ids.mapped('next_follow_up_date') + partner.itch_next_delay = min(follow_up_dates) if follow_up_dates else False diff --git a/customer_itch_cycle/models/sale_order.py b/customer_itch_cycle/models/sale_order.py new file mode 100644 index 0000000..e0fc3a1 --- /dev/null +++ b/customer_itch_cycle/models/sale_order.py @@ -0,0 +1,44 @@ + +from odoo import models, fields, api, _ +from odoo.exceptions import UserError +from datetime import datetime + +class SaleOrder(models.Model): + _inherit = 'sale.order' + + def action_confirm(self): + super(SaleOrder, self).action_confirm() + for line in self.order_line: + partner_id = self.partner_id + product_id = line.product_id + + itch_cycle_record = self.env['itch_cycle_product_partner'].search([ + ('partner_id', '=', partner_id.id), + ('product_id', '=', product_id.id) + ], limit=1) + + if itch_cycle_record: + if itch_cycle_record.last_purchase_date: + days_since_last_purchase = (datetime.now().date() - itch_cycle_record.last_purchase_date).days + if itch_cycle_record.itch_cycle_duration == 0: + raise UserError(_( + f"Veuillez définir la durée du itch-cycle pour le produit '{product_id.name}' " + f"pour le client '{partner_id.name}'.\n\n" + f"Il y a eu {days_since_last_purchase} jours depuis le dernier achat." + )) + else: + itch_cycle_record.last_purchase_date = datetime.now().date() + itch_cycle_record.itch_cycle_duration = days_since_last_purchase + else: + itch_cycle_record.last_purchase_date = datetime.now().date() + else: + new_record = self.env['itch_cycle_product_partner'].create({ + 'partner_id': partner_id.id, + 'product_id': product_id.id, + 'last_purchase_date': datetime.now().date(), + 'itch_cycle_duration': 0 + }) + raise UserError(_( + f"Il n'y a pas encore de itch-cycle défini pour le produit '{product_id.name}' " + f"avec le client '{partner_id.name}'.\n\nVeuillez entrer une durée pour ce cycle." + )) diff --git a/customer_itch_cycle/views/itch_cycle_product_partner_view.xml b/customer_itch_cycle/views/itch_cycle_product_partner_view.xml new file mode 100644 index 0000000..c1b5b83 --- /dev/null +++ b/customer_itch_cycle/views/itch_cycle_product_partner_view.xml @@ -0,0 +1,44 @@ + + + + itch.cycle.product.partner.tree + itch_cycle_product_partner + + + + + + + + + + + + + itch.cycle.product.partner.form + itch_cycle_product_partner + +
+ + + + + + + + + +
+
+
+ + + + + Itch Cycles + itch_cycle_product_partner + tree,form + +
diff --git a/customer_itch_cycle/views/res_partner_view.xml b/customer_itch_cycle/views/res_partner_view.xml new file mode 100644 index 0000000..f7285b5 --- /dev/null +++ b/customer_itch_cycle/views/res_partner_view.xml @@ -0,0 +1,23 @@ + + + + res.partner.form.itch.cycle + res.partner + + + + + + + + + + + + + + + + + + diff --git a/openai_connector/__init__.py b/openai_connector/__init__.py new file mode 100644 index 0000000..9a7e03e --- /dev/null +++ b/openai_connector/__init__.py @@ -0,0 +1 @@ +from . import models \ No newline at end of file diff --git a/openai_connector/__manifest__.py b/openai_connector/__manifest__.py new file mode 100644 index 0000000..3832c8f --- /dev/null +++ b/openai_connector/__manifest__.py @@ -0,0 +1,22 @@ +{ + 'name': 'OpenAI Connector', + 'version': '1.0', + 'category': 'Tools', + 'summary': 'Manage OpenAI connection settings', + 'license': 'AGPL-3', + 'description': ''' + This module allows the configuration of OpenAI API connection + settings, including API key and Organization ID. + ''', + 'author': 'Bemade Inc.', + 'depends': ['base_setup'], + 'data': [ + 'views/res_config_settings_views.xml', + 'security/ir.model.access.csv', + ], + 'external_dependencies': { + 'python': ['openai'], + }, + 'installable': True, + 'application': False, +} \ No newline at end of file diff --git a/openai_connector/models/__init__.py b/openai_connector/models/__init__.py new file mode 100644 index 0000000..3468e42 --- /dev/null +++ b/openai_connector/models/__init__.py @@ -0,0 +1,2 @@ +from . import res_config_settings +from . import res_company \ No newline at end of file diff --git a/openai_connector/models/res_company.py b/openai_connector/models/res_company.py new file mode 100644 index 0000000..0945476 --- /dev/null +++ b/openai_connector/models/res_company.py @@ -0,0 +1,9 @@ + +from odoo import models, fields + +class ResCompany(models.Model): + _inherit = 'res.company' + + api_key = fields.Char(string="API Key", help="API Key for OpenAI specific to this company. It should start with 'sk-'") + organization = fields.Char(string="Organization ID", help="Organization ID for OpenAI specific to this company. It should start with 'org-'") + \ No newline at end of file diff --git a/openai_connector/models/res_config_settings.py b/openai_connector/models/res_config_settings.py new file mode 100644 index 0000000..e8438f0 --- /dev/null +++ b/openai_connector/models/res_config_settings.py @@ -0,0 +1,96 @@ +from odoo import models, fields, api, _ +from odoo.exceptions import UserError +import openai +import logging + +_logger = logging.getLogger(__name__) + +class ResConfigSettings(models.TransientModel): + _inherit = 'res.config.settings' + + api_key = fields.Char( + string="API Key", + config_parameter='openai_connector.api_key', + help="API Key for OpenAI, used as a default if the company-specific key is not set. Format should start with 'sk-'" + ) + organization = fields.Char( + string="Organization ID", + config_parameter='openai_connector.organization', + help="Organization ID for OpenAI, used as a default if the company-specific ID is not set. Format should start with 'org-'" + ) + + connection_status = fields.Char( + string="Connection Status", + compute='_compute_connection_status', + help="Displays the current connection status with OpenAI." + ) + + @api.depends('api_key', 'organization') + def _compute_connection_status(self): + for record in self: + try: + if record.api_key and record.organization: # Vérifie que les champs ne sont pas vides + record._test_openai_connection() + record.connection_status = "Connected" + else: + record.connection_status = "Disconnected" + except Exception as e: + record.connection_status = "Disconnected" + _logger.error(f"OpenAI connection test failed: {str(e)}") + + def set_values(self): + super(ResConfigSettings, self).set_values() + self.env['ir.config_parameter'].sudo().set_param( + 'openai_connector.api_key', self.api_key) + self.env['ir.config_parameter'].sudo().set_param( + 'openai_connector.organization', self.organization) + + # Test de connexion automatique lors de l'enregistrement si les champs sont remplis + if self.api_key and self.organization: + self._test_openai_connection() + + @api.model + def get_values(self): + res = super(ResConfigSettings, self).get_values() + res.update( + api_key=self.env['ir.config_parameter'].sudo().get_param( + 'openai_connector.api_key', default=''), + organization=self.env['ir.config_parameter'].sudo().get_param( + 'openai_connector.organization', default='') + ) + return res + + def _test_openai_connection(self): + """Method to test connection to OpenAI.""" + if not self.api_key or not self.organization: + return # Ne fait rien si l'un des champs est vide + + try: + client = openai.OpenAI(organization=self.organization, api_key=self.api_key) + client.models.list() # Test basique de la connexion + except Exception as e: + raise UserError(_("Failed to connect to OpenAI API. Please check the API Key and Organization ID.\nError: %s") % str(e)) + + def action_test_openai_connection(self): + """Action to test connection manually and display the result.""" + try: + self._test_openai_connection() + return { + 'type': 'ir.actions.client', + 'tag': 'display_notification', + 'params': { + 'title': _("Connection Test Successful"), + 'message': _("The connection to OpenAI was successful."), + 'sticky': False, + }, + } + except UserError as e: + return { + 'type': 'ir.actions.client', + 'tag': 'display_notification', + 'params': { + 'title': _("Connection Test Failed"), + 'message': str(e), + 'sticky': True, + }, + } \ No newline at end of file diff --git a/openai_connector/security/ir.model.access.csv b/openai_connector/security/ir.model.access.csv new file mode 100644 index 0000000..1d9caaa --- /dev/null +++ b/openai_connector/security/ir.model.access.csv @@ -0,0 +1,2 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_res_config_settings_openai,res.config.settings.openai,model_res_config_settings,base.group_system,1,1,1,1 diff --git a/openai_connector/views/res_config_settings_views.xml b/openai_connector/views/res_config_settings_views.xml new file mode 100644 index 0000000..3d4131e --- /dev/null +++ b/openai_connector/views/res_config_settings_views.xml @@ -0,0 +1,46 @@ + + + + openai.connector.config.settings.form + res.config.settings + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+
+
+
+
\ No newline at end of file diff --git a/openai_partner_purchase_analysis/__init__.py b/openai_partner_purchase_analysis/__init__.py new file mode 100644 index 0000000..c536983 --- /dev/null +++ b/openai_partner_purchase_analysis/__init__.py @@ -0,0 +1,2 @@ +from . import models +from . import wizard \ No newline at end of file diff --git a/openai_partner_purchase_analysis/__manifest__.py b/openai_partner_purchase_analysis/__manifest__.py new file mode 100644 index 0000000..684df61 --- /dev/null +++ b/openai_partner_purchase_analysis/__manifest__.py @@ -0,0 +1,31 @@ +{ + 'name': 'Partner Purchase Analysis with Optional OpenAI and Queue Job', + 'version': '1.0', + 'category': 'Tools', + 'summary': 'Manage OpenAI connection settings', + 'license': 'AGPL-3', + 'description': ''' + This module allows the configuration of OpenAI API connection + settings, including API key and Organization ID. + ''', + 'author': 'Bemade Inc.', + 'depends': [ + 'base', + 'sale', + 'product', + 'openai_connector', + 'sale_management', + ], + 'data': [ + 'security/ir.model.access.csv', # Fichier de sécurité mis à jour + 'data/queue_job_group.xml', + 'views/res_config_settings_view.xml', + 'views/res_partner_view.xml', + 'wizard/partner_purchase_analysis_wizard_view.xml', + ], + 'external_dependencies': { + 'python': ['openai'], + }, + 'installable': True, + 'application': False, +} \ No newline at end of file diff --git a/openai_partner_purchase_analysis/data/queue_job_group.xml b/openai_partner_purchase_analysis/data/queue_job_group.xml new file mode 100644 index 0000000..a2ac0e3 --- /dev/null +++ b/openai_partner_purchase_analysis/data/queue_job_group.xml @@ -0,0 +1,7 @@ + + + + Use Queue Job for Asynchronous Processing + + + diff --git a/openai_partner_purchase_analysis/models/__init__.py b/openai_partner_purchase_analysis/models/__init__.py new file mode 100644 index 0000000..fbeefd0 --- /dev/null +++ b/openai_partner_purchase_analysis/models/__init__.py @@ -0,0 +1,4 @@ +from . import res_partner +from . import partner_purchase_analysis_wizard +from . import res_config_settings +from . import res_company \ No newline at end of file diff --git a/openai_partner_purchase_analysis/models/partner_purchase_analysis_wizard.py b/openai_partner_purchase_analysis/models/partner_purchase_analysis_wizard.py new file mode 100644 index 0000000..b1a6faa --- /dev/null +++ b/openai_partner_purchase_analysis/models/partner_purchase_analysis_wizard.py @@ -0,0 +1,93 @@ +from odoo import models, fields, api, _ +from odoo.exceptions import UserError +import openai + +try: + from odoo.addons.queue_job.job import job +except ImportError: + job = None # Si queue_job n'est pas disponible, job reste None + + +class PartnerPurchaseAnalysisWizard(models.TransientModel): + _name = 'partner.purchase.analysis.wizard' + _description = 'Wizard for Partner Purchase Analysis with OpenAI' + + partner_id = fields.Many2one('res.partner', string="Customer", required=True, readonly=True) + date_start = fields.Date(string="Start Date") + date_end = fields.Date(string="End Date") + analysis_result = fields.Text(string="Analysis Result", readonly=True) + + def start_analysis(self): + use_queue = self.env['ir.config_parameter'].sudo().get_param('my_module.use_queue_job') + if use_queue and job: + return self.with_delay().perform_analysis() + else: + return self.perform_analysis() + + def perform_analysis(self): + """Effectue l'analyse des ventes pour le partenaire sélectionné.""" + selected_categories = self.env.company.product_categories_analyzed + if selected_categories: + category_ids = selected_categories.ids + domain = [ + ('order_id.partner_id', '=', self.partner_id.id), + ('product_id.categ_id', 'child_of', category_ids), + ] + else: + domain = [('order_id.partner_id', '=', self.partner_id.id)] + + if self.date_start: + domain.append(('order_id.date_order', '>=', self.date_start)) + if self.date_end: + domain.append(('order_id.date_order', '<=', self.date_end)) + + sale_order_lines = self.env['sale.order.line'].search(domain) + + if not sale_order_lines: + raise UserError(_("No relevant purchase history found for this customer based on the selected filters.")) + + # Préparation des données pour l'API d'OpenAI + purchase_data = {} + for line in sale_order_lines: + category_name = line.product_id.categ_id.name or "Uncategorized" + if category_name not in purchase_data: + purchase_data[category_name] = {} + product_name = line.product_id.display_name + if product_name not in purchase_data[category_name]: + purchase_data[category_name][product_name] = [] + purchase_data[category_name][product_name].append({ + 'date': line.order_id.date_order, + 'quantity': line.product_uom_qty, + 'unit_price': line.price_unit, + }) + + # Construire le prompt pour OpenAI + user_lang = self.env.user.lang or 'en_US' + user_lang_name = self.env['res.lang'].search([('code', '=', user_lang)], limit=1).name or "English" + + purchase_details = f"Customer Purchase Analysis Grouped by Product Category and Product (Response in {user_lang_name}):\n\n" + for category, products in purchase_data.items(): + purchase_details += f"Category: {category}\n" + for product, entries in products.items(): + purchase_details += f" Product: {product}\n" + for entry in entries: + purchase_details += ( + f" - Date: {entry['date']}, " + f"Qty: {entry['quantity']}, " + f"U.Price: {entry['unit_price']}\n" + ) + purchase_details += "\n" + purchase_details += "\n" + + try: + response = openai.ChatCompletion.create( + model="gpt-4", + messages=[ + {"role": "system", + "content": f"Analyze the following customer purchase history. Identify trends, product category preferences, and any significant deviations. Respond in {user_lang_name}."}, + {"role": "user", "content": purchase_details} + ] + ) + self.analysis_result = response.choices[0].message['content'] + except Exception as e: + raise UserError(_("Failed to get response from OpenAI. Error: %s") % str(e)) \ No newline at end of file diff --git a/openai_partner_purchase_analysis/models/res_company.py b/openai_partner_purchase_analysis/models/res_company.py new file mode 100644 index 0000000..2582c6c --- /dev/null +++ b/openai_partner_purchase_analysis/models/res_company.py @@ -0,0 +1,11 @@ +from odoo import models, fields + +class ResCompany(models.Model): + _inherit = 'res.company' + + product_categories_analyzed = fields.Many2many( + 'product.category', + string="Product Categories Analyzed", + help="Select product categories to include in the purchase analysis. Only products in these categories and their subcategories will be analyzed." + ) + diff --git a/openai_partner_purchase_analysis/models/res_config_settings.py b/openai_partner_purchase_analysis/models/res_config_settings.py new file mode 100644 index 0000000..b7b1aec --- /dev/null +++ b/openai_partner_purchase_analysis/models/res_config_settings.py @@ -0,0 +1,36 @@ +from odoo import models, fields, api +from odoo.modules.module import get_module_resource + +class ResConfigSettings(models.TransientModel): + _inherit = 'res.config.settings' + + use_queue_job = fields.Boolean( + string="Use Queue Job for Asynchronous Processing", + help="If enabled, the system will use queue jobs for background tasks. Requires the queue_job module.", + default=False + ) + product_categories_analyzed = fields.Many2many( + related='company_id.product_categories_analyzed', + comodel_name='product.category', + string="Product Categories Analyzed", + help="Select product categories to include in the purchase analysis." + ) + + @api.model + def get_values(self): + res = super().get_values() + res.update( + use_queue_job=self.env['ir.config_parameter'].sudo().get_param('my_module.use_queue_job', default=False) + ) + return res + + def set_values(self): + super().set_values() + self.env['ir.config_parameter'].sudo().set_param('my_module.use_queue_job', self.use_queue_job) + + @api.model + def enable_queue_job_group(self): + """Enable the group if the queue_job module is installed""" + group = self.env.ref('partner_purchase_analysis_with_openai_filtered.group_use_queue_job', raise_if_not_found=False) + if group and get_module_resource('queue_job'): + group.sudo().write({'users': [(4, self.env.user.id)]}) \ No newline at end of file diff --git a/openai_partner_purchase_analysis/models/res_partner.py b/openai_partner_purchase_analysis/models/res_partner.py new file mode 100644 index 0000000..a37f8cf --- /dev/null +++ b/openai_partner_purchase_analysis/models/res_partner.py @@ -0,0 +1,20 @@ +from odoo import models, fields + +class ResPartner(models.Model): + _inherit = 'res.partner' + + sale_analysis = fields.Html("Sale Analysis", help="Analysis of the partner's sales.") + sale_analysis_date = fields.Date("Sale Analysis Date", help="Date of the latest sale analysis.") + + def action_open_purchase_analysis(self): + """Ouvre le wizard d'analyse des achats pour le client actuel.""" + return { + 'name': 'Purchase Analysis', + 'type': 'ir.actions.act_window', + 'res_model': 'partner.purchase.analysis.wizard', + 'view_mode': 'form', + 'target': 'new', + 'context': { + 'default_partner_id': self.id, + }, + } \ No newline at end of file diff --git a/openai_partner_purchase_analysis/security/ir.model.access.csv b/openai_partner_purchase_analysis/security/ir.model.access.csv new file mode 100644 index 0000000..fe746dd --- /dev/null +++ b/openai_partner_purchase_analysis/security/ir.model.access.csv @@ -0,0 +1,2 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_partner_purchase_analysis_wizard_sales,partner.purchase.analysis.wizard access,model_partner_purchase_analysis_wizard,sales_team.group_sale_salesman,1,1,1,1 \ No newline at end of file diff --git a/openai_partner_purchase_analysis/views/res_config_settings_view.xml b/openai_partner_purchase_analysis/views/res_config_settings_view.xml new file mode 100644 index 0000000..d2b2269 --- /dev/null +++ b/openai_partner_purchase_analysis/views/res_config_settings_view.xml @@ -0,0 +1,19 @@ + + + res.config.settings.view.form.inherit.partner.analysis + res.config.settings + + + +
+
+
+
+
+
+
+
\ No newline at end of file diff --git a/openai_partner_purchase_analysis/views/res_partner_view.xml b/openai_partner_purchase_analysis/views/res_partner_view.xml new file mode 100644 index 0000000..9fb4018 --- /dev/null +++ b/openai_partner_purchase_analysis/views/res_partner_view.xml @@ -0,0 +1,17 @@ + + + res.partner.form.inherit.purchase.analysis + res.partner + + + + + + + + + + + + + \ No newline at end of file diff --git a/openai_partner_purchase_analysis/wizard/__init__.py b/openai_partner_purchase_analysis/wizard/__init__.py new file mode 100644 index 0000000..7e6aa79 --- /dev/null +++ b/openai_partner_purchase_analysis/wizard/__init__.py @@ -0,0 +1 @@ +from . import partner_purchase_analysis_wizard \ No newline at end of file diff --git a/openai_partner_purchase_analysis/wizard/partner_purchase_analysis_wizard.py b/openai_partner_purchase_analysis/wizard/partner_purchase_analysis_wizard.py new file mode 100644 index 0000000..d4d8fcb --- /dev/null +++ b/openai_partner_purchase_analysis/wizard/partner_purchase_analysis_wizard.py @@ -0,0 +1,141 @@ +from odoo import models, fields, api, _ +from odoo.exceptions import UserError +from openai import OpenAI +from datetime import datetime +from bs4 import BeautifulSoup +from markupsafe import escape + +# Import conditionnel de queue_job pour gérer les files d'attente si elles sont disponibles +try: + from odoo.addons.queue_job.job import job +except ImportError: + job = None # Si queue_job n'est pas disponible, job reste None + +class PartnerPurchaseAnalysisWizard(models.TransientModel): + _name = 'partner.purchase.analysis.wizard' + _description = 'Wizard for Partner Purchase Analysis with OpenAI' + + partner_id = fields.Many2one('res.partner', string="Customer", required=True, readonly=True) + date_start = fields.Date(string="Start Date", required=True) + date_end = fields.Date(string="End Date", default=fields.Date.today, required=True) + + def start_analysis(self): + """Lance l'analyse en arrière-plan si `queue_job` est disponible, sinon exécute immédiatement.""" + use_queue = self.env['ir.config_parameter'].sudo().get_param('my_module.use_queue_job') + if use_queue and job: + return self.with_delay().perform_analysis() + else: + return self.perform_analysis() + + def perform_analysis(self): + """Effectue l'analyse des ventes pour le partenaire sélectionné.""" + + # Récupération de l'organisation et de la clé API OpenAI dans les paramètres + organization = self.env['ir.config_parameter'].sudo().get_param('openai_connector.organization') + api_key = self.env['ir.config_parameter'].sudo().get_param('openai_connector.api_key') + + if not api_key or not organization: + raise UserError(_("API Key or Organization ID for OpenAI is missing in settings.")) + + client = OpenAI( + api_key=api_key, + organization=organization + ) + + selected_categories = self.env.company.product_categories_analyzed + if selected_categories: + category_ids = selected_categories.ids + domain = [ + ('order_id.partner_id', '=', self.partner_id.id), + ('product_id.categ_id', 'child_of', category_ids), + ] + else: + domain = [('order_id.partner_id', '=', self.partner_id.id)] + + if self.date_start: + domain.append(('order_id.date_order', '>=', self.date_start)) + if self.date_end: + domain.append(('order_id.date_order', '<=', self.date_end)) + + sale_order_lines = self.env['sale.order.line'].search(domain) + + if not sale_order_lines: + raise UserError(_("No relevant purchase history found for this customer based on the selected filters.")) + + # Préparation des données pour le prompt OpenAI + purchase_data = {} + for line in sale_order_lines: + category_name = line.product_id.categ_id.name or "Uncategorized" + if category_name not in purchase_data: + purchase_data[category_name] = {} + product_name = line.product_id.display_name + if product_name not in purchase_data[category_name]: + purchase_data[category_name][product_name] = [] + purchase_data[category_name][product_name].append({ + 'date': line.order_id.date_order, + 'quantity': line.product_uom_qty, + 'unit_price': line.price_unit, + }) + + # Prompt défini en plusieurs lignes pour lisibilité + user_lang = self.env.user.lang or 'en_US' + user_lang_name = self.env['res.lang'].search([('code', '=', user_lang)], limit=1).name or "English" + + prompt = ( + "Analyze the following customer purchase history. Identify trends, product " + "category preferences, and any significant deviations. Respond in " + f"{user_lang_name}. Produce graph and table of the analysis. You output all " + "in html format." + ) + + # Construction de `purchase_details` pour le contenu du prompt + purchase_details = f"Customer Purchase Analysis Grouped by Product Category and Product:\n\n" + for category, products in purchase_data.items(): + purchase_details += f"Category: {category}\n" + for product, entries in products.items(): + purchase_details += f" Product: {product}\n" + for entry in entries: + purchase_details += ( + f" - Date: {entry['date']}, " + f"Qty: {entry['quantity']}, " + f"U.Price: {entry['unit_price']}\n" + ) + purchase_details += "\n" + purchase_details += "\n" + + # Appel à l'API OpenAI + try: + response = client.chat.completions.create( + messages=[ + { + "role": "system", + "content": prompt + }, + { + "role": "user", + "content": purchase_details + } + ], + model="gpt-4o", + ) + + # Supposons que `response` contient la réponse complète au format HTML + response_text = response.choices[0].message.content + + # Parse avec BeautifulSoup pour extraire le contenu du + soup = BeautifulSoup(response_text, "html.parser") + body_content = soup.body + + # Si body est présent, on utilise son contenu ; sinon, on utilise tout le texte + cleaned_text = escape(body_content.get_text()) if body_content else escape(response_text) + + # Ajouter des balises de base pour structurer le texte en HTML simple + html_content = f"
{cleaned_text.replace('\n', '
')}
" + + self.partner_id.sale_analysis = body_content + self.partner_id.sale_analysis_date = fields.Datetime.today() +# self.sale_analysis = response.choices[0].message.content +# self.sale_analysis_date = fields.Datetime.today() + print(response.choices[0].message.content) + except Exception as e: + raise UserError(_("Failed to get response from OpenAI. Error: %s") % str(e)) \ No newline at end of file diff --git a/openai_partner_purchase_analysis/wizard/partner_purchase_analysis_wizard_view.xml b/openai_partner_purchase_analysis/wizard/partner_purchase_analysis_wizard_view.xml new file mode 100644 index 0000000..75f0238 --- /dev/null +++ b/openai_partner_purchase_analysis/wizard/partner_purchase_analysis_wizard_view.xml @@ -0,0 +1,45 @@ + + + partner.purchase.analysis.wizard.form + partner.purchase.analysis.wizard + +
+ + + + +