port of durpro_helpdek_sale to helpdesk sale order + base for AI

This commit is contained in:
xtremxpert 2025-07-09 08:23:03 -04:00
parent 2164e2be54
commit aa16543a86
23 changed files with 639 additions and 0 deletions

View file

@ -0,0 +1,6 @@
# -*- coding: utf-8 -*-
from . import models
from . import wizard
# vim:expandtab:smartindent:tabstop=4:softtabstop=4:shiftwidth=4:

View file

@ -0,0 +1,32 @@
# -*- coding: utf-8 -*-
{
'name': 'Helpdesk Sale Order',
'license': 'LGPL-3',
'version': '18.0.0.1',
'category' : 'Sales/Sales',
'summary': """Allows your helpdesk team to create sales orders from helpdesk tickets.""",
'description': """
Convert helpdesk tickets into sales orders.
This feature is useful for companies that sell products or services to their customers
and might receive orders through their helpdesk tickets or simply used orders@theirodoo.com
as a triage helpdesk ticket.
""",
'author': 'Bemade',
'maintainer': 'it@bemade.org',
'depends': [
'sale_management',
'helpdesk',
'helpdesk_sale',
'sale_project'
],
'data': [
'views/account_move_view.xml',
'views/helpdesk_ticket_views.xml',
'views/sale_view.xml',
'views/helpdesk_team_views.xml'
],
'installable': True,
'application': False,
}

View file

@ -0,0 +1,22 @@
# Migration de durpro_helpdesk_sale vers Odoo 18.0
## Description
Module d'intégration entre le helpdesk et les ventes pour Durpro.
## Fonctionnalités Ajoutées
### Création de commandes de vente depuis les tickets
- Vérification Odoo 18.0 : À vérifier
- Différences avec la version native : À documenter
- Alternatives disponibles : À identifier
## Modèles et Champs Modifiés
### Modèle HelpdeskTicket
- Ajouts/Modifications : À documenter
- Recherche dans le projet : À effectuer
- Existence dans Odoo standard/enterprise : À vérifier
- Recommandations de migration : À formuler
## Vues à Modifier
- Liste des vues tree à convertir en list : À identifier

View file

@ -0,0 +1,8 @@
# -*- coding: utf-8 -*-
from . import sale_order
from . import account_move
from . import helpdesk_team
from . import helpdesk_ticket
# vim:expandtab:smartindent:tabstop=4:softtabstop=4:shiftwidth=4:

View file

@ -0,0 +1,12 @@
# -*- coding: utf-8 -*-
from odoo import models, fields, api
class AccountMove(models.Model):
_inherit = 'account.move'
helpdesk_ticket_id = fields.Many2one(
'helpdesk.ticket',
string='Helpdesk Ticket',
copy=False,
)

View file

@ -0,0 +1,7 @@
from odoo import fields, models
class HelpdeskTeam(models.Model):
_inherit = 'helpdesk.team'
use_sale_orders = fields.Boolean('Sales', help="Create quotes from tickets")

View file

@ -0,0 +1,90 @@
# -*- coding: utf-8 -*-
from odoo import models, fields, api, _, Command
from odoo.exceptions import UserError
class HelpdeskTicket(models.Model):
_inherit = "helpdesk.ticket"
sale_order_ids = fields.One2many(
"sale.order",
"helpdesk_ticket_id",
string="Sale Orders",
help="Sale orders associated to this ticket.",
copy=False,
)
sale_order_count = fields.Integer(compute="_compute_sale_order_count")
team_use_sale_orders = fields.Boolean(related="team_id.use_sale_orders", string="Team Uses Sales Orders", readonly=True)
@api.depends("sale_order_ids")
def _compute_sale_order_count(self):
self.sale_order_count = len(self.sale_order_ids)
def action_view_sale_order(self):
self.ensure_one()
sale_order_form_view = self.env.ref("sale.view_order_form")
sale_order_tree_view = self.env.ref("sale.view_order_tree")
action = {
"type": "ir.actions.act_window",
"res_model": "sale.order",
"context": {"create": False},
}
if self.sale_order_count == 1:
action.update(
res_id=self.sale_order_ids[0].id,
views=[(sale_order_form_view.id, "form")],
)
else:
action.update(
domain=[("id", "in", self.sale_order_ids.ids)],
views=[
(sale_order_tree_view.id, "tree"),
(sale_order_form_view.id, "form"),
],
name=_("Purchase Orders from Ticket"),
)
return action
def action_convert_to_sale_order(self):
self.ensure_one()
if not self.team_use_sale_orders:
raise UserError(_("Creating quotes from tickets is not enabled for this helpdesk team."))
so_values = self._generate_so_values()
so = self.env["sale.order"].create([so_values])
self.message_change_thread(so)
attachments = self.env["ir.attachment"].search(
[("res_model", "=", "helpdesk.ticket"), ("res_id", "=", self.id)]
)
attachments.sudo().write({"res_model": "sale.order", "res_id": so.id})
activities = self.activity_ids
activities.sudo().write(
{
"res_model_id": self.env.ref("sale.model_sale_order").id,
"res_id": so.id,
"res_model": "sale.order",
}
)
# The activities will be linked to the SO through the res_model and res_id fields
self.action_archive()
return self.action_view_sale_order()
def _generate_so_values(self):
team = self.user_id.sale_team_id if self.user_id else self.env.user.sale_team_id
if not team:
raise UserError(
_(
"Creating sale orders is reserved to sales users. Assign the user to sale team first."
)
)
team_id = team.id
return {
"partner_id": self.partner_id.id,
"helpdesk_ticket_id": self.id,
"company_id": self.company_id.id,
"team_id": team_id,
}

View file

@ -0,0 +1,37 @@
# -*- coding: utf-8 -*-
from odoo import models, fields, api
class SaleOrder(models.Model):
_inherit = 'sale.order'
helpdesk_ticket_id = fields.Many2one(
'helpdesk.ticket',
string='Helpdesk Ticket',
copy=False,
)
def action_view_ticket(self):
return {
'type': 'ir.actions.act_window',
'res_model': 'helpdesk.ticket',
'view_mode': 'form',
'res_id': self.helpdesk_ticket_id.id,
}
def _prepare_invoice(self):
result = super(SaleOrder, self)._prepare_invoice()
result.update({'helpdesk_ticket_id': self.helpdesk_ticket_id.id})
return result
def create(self, vals_list):
sos = super().create(vals_list)
for so in sos.filtered('helpdesk_ticket_id'):
so.message_post_with_source(
'helpdesk.ticket_creation',
render_values={
'self': so,
'ticket': so.helpdesk_ticket_id
}, subtype_id=self.env.ref('mail.mt_note').id
)
return sos

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

View file

@ -0,0 +1 @@
from . import test_helpdesk_sale

View file

@ -0,0 +1,65 @@
from odoo.tests import TransactionCase, tagged
from odoo import Command
@tagged("post_install", "-at_install")
class TestHelpdeskSale(TransactionCase):
def test_convert_ticket_to_sale_order_moves_messages(self):
# User needs to be part of a sales team, so set that up first
sale_team = self.env.ref("sales_team.crm_team_1")
helpdesk_team = self.env.ref("helpdesk.helpdesk_team1")
self.env.user.sale_team_id = sale_team
ticket = self.env["helpdesk.ticket"].create(
{
"name": "Test Ticket",
"description": "Test description",
"partner_id": self.env.ref("base.res_partner_2").id,
"user_id": self.env.user.id,
"team_id": helpdesk_team.id,
}
)
ticket.message_post(body="This is a test email message")
ticket.message_post(body="This is another message")
ticket.message_post(body="This is a comment.", message_type="comment")
num_messages = len(ticket.message_ids)
self.assertGreaterEqual(num_messages, 3)
messages = ticket.message_ids
action = ticket.action_convert_to_sale_order()
so = self.env["sale.order"].browse(action["res_id"])
for message in messages:
self.assertIn(message.body, so.message_ids.mapped("body"))
self.assertFalse(ticket.active)
def test_convert_ticket_to_sale_order_moves_activities(self):
# User needs to be part of a sales team, so set that up first
sale_team = self.env.ref("sales_team.crm_team_1")
helpdesk_team = self.env.ref("helpdesk.helpdesk_team1")
self.env.user.sale_team_id = sale_team
ticket = self.env["helpdesk.ticket"].create(
{
"name": "Test Ticket",
"description": "Test description",
"partner_id": self.env.ref("base.res_partner_2").id,
"user_id": self.env.user.id,
"team_id": helpdesk_team.id,
}
)
activity = self.env["mail.activity"].create(
{
"activity_type_id": self.env.ref("mail.mail_activity_data_call").id,
"res_model_id": self.env.ref("helpdesk.model_helpdesk_ticket").id,
"res_id": ticket.id,
"user_id": self.env.user.id,
}
)
action = ticket.action_convert_to_sale_order()
self.env.invalidate_all(flush=True)
so = self.env["sale.order"].browse(action["res_id"])
self.assertIn(activity, so.activity_ids)

View file

@ -0,0 +1,18 @@
<?xml version="1.0"?>
<odoo>
<data>
<record id="view_move_form_inherit_helpdesk_custom" model="ir.ui.view">
<field name="name">account.move.from.inherited.helpdesk</field>
<field name="model">account.move</field>
<field name="inherit_id" ref="account.view_move_form"/>
<field name="arch" type="xml">
<xpath expr="//field[@name='invoice_origin']" position="after">
<field name="helpdesk_ticket_id" readonly="state not in ['draft', 'sent']"/>
</xpath>
</field>
</record>
</data>
</odoo>

View file

@ -0,0 +1,15 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="helpdesk_team_view_form_inherit_sale_order" model="ir.ui.view">
<field name="name">helpdesk.team.form.inherit.sale.order</field>
<field name="model">helpdesk.team</field>
<field name="inherit_id" ref="helpdesk.helpdesk_team_view_form"/>
<field name="arch" type="xml">
<xpath expr="//div[@id='after-sales']" position="inside">
<setting help="Create quotes from tickets">
<field name="use_sale_orders"/>
</setting>
</xpath>
</field>
</record>
</odoo>

View file

@ -0,0 +1,32 @@
<?xml version="1.0"?>
<odoo>
<data>
<record id="helpdesk_ticket_form_view_inherit_saleorder" model="ir.ui.view">
<field name="name">helpdesk.ticket.form.view.inherit.saleorder</field>
<field name="model">helpdesk.ticket</field>
<field name="inherit_id" ref="helpdesk.helpdesk_ticket_view_form"/>
<field name="arch" type="xml">
<xpath expr="//header" position="inside">
<button name="action_convert_to_sale_order"
string="Convert to Quotation"
groups="helpdesk.group_helpdesk_manager,helpdesk.group_helpdesk_user"
type="object"
class="btn btn-secondary"
invisible="not team_use_sale_orders"/>
</xpath>
<xpath expr="//div[@name='button_box']" position="inside">
<button class="oe_stat_button"
name="action_view_sale_order"
icon="fa-usd"
type="object"
invisible="sale_order_count == 0">
<field string="Sale Orders" name="sale_order_count" widget="statinfo" />
</button>
</xpath>
<field name="team_id" position="after">
<field name="team_use_sale_orders" invisible="1"/>
</field>
</field>
</record>
</data>
</odoo>

View file

@ -0,0 +1,26 @@
<?xml version="1.0"?>
<odoo>
<data>
<record id="view_order_form_inherit_sales" model="ir.ui.view">
<field name="name">sale.order.from.inherited.saleorder</field>
<field name="model">sale.order</field>
<field name="inherit_id" ref="sale.view_order_form"/>
<field name="arch" type="xml">
<xpath expr="//div[@name='button_box']" position="inside">
<field string="Ticket" name="helpdesk_ticket_id" invisible="1" />
<button
string="Ticket"
class="oe_stat_button"
name="action_view_ticket"
type="object"
icon="fa-life-ring"
help="Tickets for this order"
invisible="helpdesk_ticket_id == False"/>
</xpath>
</field>
</record>
</data>
</odoo>

View file

@ -0,0 +1,4 @@
# -*- coding: utf-8 -*-
from . import sale_advance_payment
# vim:expandtab:smartindent:tabstop=4:softtabstop=4:shiftwidth=4:

View file

@ -0,0 +1,10 @@
# -*- coding: utf-8 -*-
from odoo import models, fields, api
class SaleAdvancePaymentInv(models.TransientModel):
_inherit = "sale.advance.payment.inv"
def _create_invoice(self, order, so_line, amount):
res = super(SaleAdvancePaymentInv, self)._create_invoice(order, so_line, amount)
res.helpdesk_ticket_id = order.helpdesk_ticket_id
return res

View file

@ -0,0 +1,3 @@
# -*- coding: utf-8 -*-
from . import models

View file

@ -0,0 +1,27 @@
# -*- coding: utf-8 -*-
{
'name': 'Helpdesk Sale Order AI',
'license': 'LGPL-3',
'version': '18.0.0.1',
'category': 'Sales/Sales',
'summary': """Automatically create sales orders from helpdesk tickets using AI.""",
'description': """
Extends the Helpdesk Sale Order module to automatically create sales orders from helpdesk tickets using AI.
This module adds AI capabilities to analyze ticket content and automatically generate appropriate sales orders
with relevant products and services based on the ticket description.
""",
'author': 'Bemade',
'maintainer': 'it@bemade.org',
'depends': [
'helpdesk_sale_order',
'openai_connector', # Supposant qu'un module de connexion OpenAI existe
],
'data': [
'views/helpdesk_team_views.xml',
'views/helpdesk_ticket_views.xml',
],
'installable': True,
'application': False,
}

View file

@ -0,0 +1,4 @@
# -*- coding: utf-8 -*-
from . import helpdesk_ticket
from . import helpdesk_team

View file

@ -0,0 +1,25 @@
# -*- coding: utf-8 -*-
from odoo import models, fields, api
class HelpdeskTeam(models.Model):
_inherit = 'helpdesk.team'
use_ai_sale_orders = fields.Boolean(
string='Use AI for Sale Orders',
help='If checked, the system will use AI to automatically generate sale orders from ticket descriptions.',
default=False,
)
ai_prompt_template = fields.Text(
string='AI Prompt Template',
help='Template for the prompt sent to the AI. Use placeholders like {description}, {customer}, etc.',
default="""Based on the following helpdesk ticket description, identify products and services that should be included in a sales order:
Ticket Description: {description}
Customer: {customer}
Please provide a list of products/services with quantities and descriptions in the following format:
Product/Service Name | Quantity | Description
"""
)

View file

@ -0,0 +1,181 @@
# -*- coding: utf-8 -*-
from odoo import models, fields, api, _
from odoo.exceptions import UserError
import logging
import json
_logger = logging.getLogger(__name__)
class HelpdeskTicket(models.Model):
_inherit = 'helpdesk.ticket'
# Utiliser un champ calculé au lieu d'un champ simple avec onchange
team_use_ai_sale_orders = fields.Boolean(
string='Team Uses AI for Sale Orders',
compute='_compute_team_use_ai_sale_orders',
)
@api.depends('team_id')
def _compute_team_use_ai_sale_orders(self):
for ticket in self:
ticket.team_use_ai_sale_orders = False
if ticket.team_id:
# Vérifier si le champ existe sur l'équipe
team = self.env['helpdesk.team'].sudo().browse(ticket.team_id.id)
if hasattr(team, 'use_ai_sale_orders'):
ticket.team_use_ai_sale_orders = team.use_ai_sale_orders
ai_generated_products = fields.Text(
string='AI Generated Products',
readonly=True,
help='Products suggested by AI based on ticket description',
)
def action_convert_to_sale_order(self):
"""Override to use AI for generating sale order if enabled"""
self.ensure_one()
# Check if the team allows sale orders
if not self.team_use_sale_orders:
raise UserError(_("You cannot create a sale order from this ticket because your team does not allow it."))
# Vérifier directement sur l'équipe si l'IA est activée
use_ai = False
if self.team_id and hasattr(self.team_id, 'use_ai_sale_orders'):
use_ai = self.team_id.use_ai_sale_orders
# If AI is enabled for this team, use it to generate the sale order
if use_ai:
return self._ai_convert_to_sale_order()
# Otherwise, use the standard method from the parent module
return super(HelpdeskTicket, self).action_convert_to_sale_order()
def _ai_convert_to_sale_order(self):
"""Create a sale order using AI to suggest products based on ticket description"""
self.ensure_one()
# Generate AI suggestions if not already done
if not self.ai_generated_products:
self._generate_ai_product_suggestions()
# Create the sale order with AI-suggested products
so_vals = self._generate_ai_so_values()
sale_order = self.env['sale.order'].create([so_vals])
# Link the sale order to the ticket
self.write({
'sale_order_id': sale_order.id,
})
# Return the action to view the created sale order
return {
'type': 'ir.actions.act_window',
'name': _('Sale Order'),
'res_model': 'sale.order',
'res_id': sale_order.id,
'view_mode': 'form',
'context': {'create': False},
}
def _generate_ai_product_suggestions(self):
"""Use AI to generate product suggestions based on ticket description"""
self.ensure_one()
# Skip if no description
if not self.description:
return False
try:
# Prepare the prompt using the template from the team
prompt = self.team_id.ai_prompt_template.format(
description=self.description,
customer=self.partner_id.name or 'Unknown',
)
# Call the AI service (assuming an OpenAI connector module exists)
ai_service = self.env['openai.service'].sudo()
response = ai_service.generate_completion(prompt)
# Store the AI response
self.ai_generated_products = response
return True
except Exception as e:
_logger.error("Error generating AI product suggestions: %s", str(e))
return False
def _generate_ai_so_values(self):
"""Generate sale order values with AI-suggested products"""
# Start with the base SO values from the parent method
so_vals = self._generate_so_values()
# Parse AI suggestions and add as order lines
if self.ai_generated_products:
order_lines = self._parse_ai_product_suggestions()
if order_lines:
so_vals['order_line'] = order_lines
return so_vals
def _parse_ai_product_suggestions(self):
"""Parse the AI-generated product suggestions into sale order lines"""
order_lines = []
if not self.ai_generated_products:
return order_lines
# Simple parsing of the AI response
# Format expected: Product/Service Name | Quantity | Description
lines = self.ai_generated_products.strip().split('\n')
for line in lines:
if '|' not in line:
continue
parts = [part.strip() for part in line.split('|')]
if len(parts) < 2:
continue
product_name = parts[0]
quantity = 1.0
description = ''
# Try to parse quantity
if len(parts) > 1:
try:
quantity = float(parts[1])
except ValueError:
quantity = 1.0
# Get description if available
if len(parts) > 2:
description = parts[2]
# Search for matching product
product = self.env['product.product'].search([
('name', 'ilike', product_name),
('sale_ok', '=', True)
], limit=1)
if not product:
# If no product found, create a service product
product = self.env['product.product'].create({
'name': product_name,
'type': 'service',
'sale_ok': True,
'purchase_ok': False,
'list_price': 0.0,
})
# Create order line
order_line = (0, 0, {
'product_id': product.id,
'product_uom_qty': quantity,
'name': description or product.name,
})
order_lines.append(order_line)
return order_lines

View file

@ -0,0 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="helpdesk_team_view_form_inherit_helpdesk_sale_order_ai" model="ir.ui.view">
<field name="name">helpdesk.team.form.inherit.sale.order.ai</field>
<field name="model">helpdesk.team</field>
<field name="inherit_id" ref="helpdesk.helpdesk_team_view_form"/>
<field name="arch" type="xml">
<xpath expr="//field[@name='team_use_sale_orders']" position="after">
<field name="use_ai_sale_orders" attrs="{'invisible': [('team_use_sale_orders', '=', False)]}"/>
<field name="ai_prompt_template" attrs="{'invisible': [('use_ai_sale_orders', '=', False)]}" widget="text_field"/>
</xpath>
</field>
</record>
</odoo>