Compare commits
48 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2750f9268a | ||
|
|
401818765a | ||
|
|
d1e97e3448 | ||
|
|
0b5a791e1f | ||
|
|
4b19f8262e | ||
|
|
3ebadcb969 | ||
|
|
db10db822c | ||
|
|
9fb835db34 | ||
|
|
512bf58da8 | ||
|
|
873a7ba71a | ||
|
|
b19da588cd | ||
|
|
ab60babd7d | ||
|
|
055ceede53 | ||
|
|
9859a3cf96 | ||
|
|
57b401ca1a | ||
|
|
2322dbaae1 | ||
|
|
81a4a904c7 | ||
|
|
95a95298bb | ||
|
|
f9f32aab34 | ||
|
|
65fbcb9b33 | ||
|
|
799e944112 | ||
|
|
7ab530bd29 | ||
|
|
b1a625a0e4 | ||
|
|
45c1bdf440 | ||
|
|
370ebae08b | ||
|
|
d334b63696 | ||
|
|
424e9e5159 | ||
|
|
1db303d0a5 | ||
|
|
5050839056 | ||
|
|
010752b01f | ||
|
|
d644841f99 | ||
|
|
3ba182fe0e | ||
|
|
b06e9c4569 | ||
|
|
f3a7072314 | ||
|
|
57c938bb8d | ||
|
|
fa4f1b2b19 | ||
|
|
536f10b61e | ||
|
|
f6ab94e0b3 | ||
|
|
9578594628 | ||
|
|
5a97745d42 | ||
|
|
a0ff56ade2 | ||
|
|
8ce49de715 | ||
|
|
3f6f452fe1 | ||
|
|
8c1758dacd | ||
|
|
58e49f0db5 | ||
|
|
5b18adb083 | ||
|
|
fb057ada9a | ||
|
|
7783b04e73 |
219 changed files with 17026 additions and 386 deletions
|
|
@ -20,7 +20,7 @@
|
|||
########################################################################################
|
||||
{
|
||||
"name": "Improved Field Service Management",
|
||||
"version": "17.0.0.4.2",
|
||||
"version": "17.0.0.4.1",
|
||||
"summary": (
|
||||
"Adds functionality necessary for managing field service operations at Durpro."
|
||||
),
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -5,7 +5,8 @@ class SaleOrder(models.Model):
|
|||
_inherit = "sale.order"
|
||||
|
||||
valid_equipment_ids = fields.One2many(
|
||||
comodel_name="fsm.equipment", related="partner_id.owned_equipment_ids"
|
||||
comodel_name="fsm.equipment",
|
||||
related="partner_id.commercial_partner_id.owned_equipment_ids",
|
||||
)
|
||||
|
||||
default_equipment_ids = fields.Many2many(
|
||||
|
|
@ -50,7 +51,6 @@ class SaleOrder(models.Model):
|
|||
store=True,
|
||||
)
|
||||
|
||||
@api.depends("order_line.task_id")
|
||||
def get_relevant_order_lines(self, task_id):
|
||||
self.ensure_one()
|
||||
linked_lines = self.order_line.filtered(
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -111,14 +111,16 @@ class SaleOrderLine(models.Model):
|
|||
for t in template.subtasks:
|
||||
subtask = _create_task_from_template(project, t, task)
|
||||
subtasks.append(subtask)
|
||||
# task.write({"child_ids": [Command.set([t.id for t in subtasks])]})
|
||||
|
||||
# We don't want to see the sub-tasks on the SO
|
||||
task.child_ids.write(
|
||||
{
|
||||
"sale_order_id": None,
|
||||
"sale_line_id": None,
|
||||
}
|
||||
)
|
||||
if task.child_ids:
|
||||
task.child_ids.write(
|
||||
{
|
||||
"sale_order_id": None,
|
||||
"sale_line_id": None,
|
||||
}
|
||||
)
|
||||
|
||||
return task
|
||||
|
||||
def _timesheet_create_task_prepare_values_from_template(
|
||||
|
|
@ -142,7 +144,11 @@ class SaleOrderLine(models.Model):
|
|||
vals["tag_ids"] = template.tags.ids
|
||||
vals["allocated_hours"] = template.planned_hours
|
||||
vals["sequence"] = template.sequence
|
||||
vals["partner_id"] = self.order_id.partner_id.id
|
||||
# Use shipping address for FSM tasks for consistency
|
||||
if project and project.is_fsm:
|
||||
vals["partner_id"] = self.order_id.partner_shipping_id.id
|
||||
else:
|
||||
vals["partner_id"] = self.order_id.partner_id.id
|
||||
if template.equipment_ids:
|
||||
vals["equipment_ids"] = template.equipment_ids.ids
|
||||
return vals
|
||||
|
|
@ -150,6 +156,9 @@ class SaleOrderLine(models.Model):
|
|||
tmpl = self.product_id.task_template_id
|
||||
if not tmpl:
|
||||
task = super()._timesheet_create_task(project)
|
||||
# For FSM tasks without a template, update partner_id to use shipping address
|
||||
if project.is_fsm and task:
|
||||
task.partner_id = self.order_id.partner_shipping_id.id
|
||||
else:
|
||||
task = _create_task_from_template(project, tmpl, None)
|
||||
self.write({"task_id": task.id})
|
||||
|
|
|
|||
78
bemade_fsm/models/sale_order_template.py
Normal file
78
bemade_fsm/models/sale_order_template.py
Normal file
|
|
@ -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",
|
||||
)
|
||||
|
|
@ -65,6 +65,7 @@ class Task(models.Model):
|
|||
res = super().create(vals)
|
||||
for rec in res:
|
||||
if rec.parent_id and rec.is_fsm:
|
||||
# Always ensure FSM subtasks have a partner_id set from their parent
|
||||
rec.partner_id = rec.parent_id.partner_id
|
||||
if not rec.work_order_contacts and rec.parent_id:
|
||||
rec.work_order_contacts = rec.parent_id.work_order_contacts
|
||||
|
|
@ -123,7 +124,8 @@ class Task(models.Model):
|
|||
)
|
||||
if "partner_id" in vals:
|
||||
child_vals.update(partner_id=vals["partner_id"])
|
||||
rec.child_ids.write(child_vals)
|
||||
if child_vals:
|
||||
rec.child_ids.write(child_vals)
|
||||
return res
|
||||
|
||||
@api.depends("sale_order_id")
|
||||
|
|
@ -220,3 +222,42 @@ class Task(models.Model):
|
|||
def _compute_root_ancestor(self):
|
||||
for rec in self:
|
||||
rec.root_ancestor = rec.parent_id and rec.parent_id.root_ancestor or self
|
||||
|
||||
@api.depends(
|
||||
"partner_id",
|
||||
"sale_line_id.order_partner_id",
|
||||
"parent_id.sale_line_id",
|
||||
"project_id.sale_line_id",
|
||||
"milestone_id.sale_line_id",
|
||||
"allow_billable",
|
||||
)
|
||||
def _compute_sale_line(self):
|
||||
"""Override to prevent subtasks from inheriting parent's sale_line_id.
|
||||
|
||||
In the base implementation, if a task and its parent share the same commercial partner,
|
||||
the task will inherit the parent's sale_line_id. This causes issues with our FSM tasks
|
||||
where we explicitly want subtasks to NOT have a sale_line_id set.
|
||||
"""
|
||||
|
||||
# Only run on root tasks
|
||||
subtasks = self.filtered("parent_id")
|
||||
(subtasks - subtasks.filtered("sale_line_id")).sale_line_id = False
|
||||
super(Task, self - subtasks)._compute_sale_line()
|
||||
|
||||
@api.depends("parent_id.partner_id", "project_id")
|
||||
def _compute_partner_id(self):
|
||||
"""Override to prevent clearing partner_id for FSM tasks.
|
||||
|
||||
In the base implementation, if a task has a partner_id but no project_id or parent_id,
|
||||
the partner_id is cleared. This causes issues with our FSM tasks where we want to
|
||||
preserve the partner_id even if project_id or parent_id is temporarily not set.
|
||||
"""
|
||||
# Only run the standard logic on non-FSM tasks
|
||||
non_fsm_tasks = self.filtered(lambda t: not t.is_fsm)
|
||||
super(Task, non_fsm_tasks)._compute_partner_id()
|
||||
|
||||
# For FSM tasks, only set partner_id if it's not already set
|
||||
fsm_tasks = self - non_fsm_tasks
|
||||
for task in fsm_tasks:
|
||||
if not task.partner_id:
|
||||
task.partner_id = self._get_default_partner_id(task.project_id, task.parent_id)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
|
@ -42,6 +42,9 @@ class BemadeFSMBaseTest(TransactionCase):
|
|||
user_group_fsm_user = cls.env.ref("industry_fsm.group_fsm_user")
|
||||
user_group_sales_user = cls.env.ref("sales_team.group_sale_salesman")
|
||||
user_group_sales_manager = cls.env.ref("sales_team.group_sale_manager")
|
||||
user_group_delivery_address = cls.env.ref(
|
||||
"account.group_delivery_invoice_address"
|
||||
)
|
||||
user_product_customer = cls.env.ref(
|
||||
"customer_product_code.group_product_customer_code_user",
|
||||
raise_if_not_found=False,
|
||||
|
|
@ -53,6 +56,7 @@ class BemadeFSMBaseTest(TransactionCase):
|
|||
user_group_fsm_user.id,
|
||||
user_group_sales_manager.id,
|
||||
user_group_sales_user.id,
|
||||
user_group_delivery_address.id,
|
||||
]
|
||||
if user_product_customer:
|
||||
group_ids.append(user_product_customer.id)
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ class TestEquipment(BemadeFSMBaseTest):
|
|||
partner = self._generate_partner()
|
||||
partner_2 = self._generate_partner()
|
||||
equipment_1 = self._generate_equipment(partner=partner)
|
||||
equipment_2 = self._generate_equipment(partner_2)
|
||||
equipment_2 = self._generate_equipment(partner=partner_2)
|
||||
sale_order = self._generate_sale_order(partner=partner)
|
||||
product = self._generate_product()
|
||||
self.assertEqual(sale_order.valid_equipment_ids, equipment_1)
|
||||
|
|
|
|||
|
|
@ -142,3 +142,55 @@ class FSMVisitTest(BemadeFSMBaseTest):
|
|||
|
||||
supposed_name = "SVR12345-1 - Test Company - Test Label"
|
||||
self.assertEqual(task.name, supposed_name)
|
||||
|
||||
def test_subtasks_inherit_partner_from_parent_task(self):
|
||||
"""Test that subtasks of tasks created from FSM sales orders have their partner_id set correctly."""
|
||||
# Create a sale order with a shipping address different from the billing address
|
||||
partner = self._generate_partner(name="Customer")
|
||||
shipping_partner = self.env['res.partner'].create({
|
||||
'name': 'Shipping Address',
|
||||
'parent_id': partner.id,
|
||||
'type': 'delivery',
|
||||
})
|
||||
|
||||
# Create a sale order with the customer and shipping address
|
||||
so = self._generate_sale_order(partner=partner)
|
||||
so.partner_shipping_id = shipping_partner
|
||||
|
||||
# Create a visit
|
||||
visit = self._generate_visit(sale_order=so)
|
||||
|
||||
# Create a product with a task template that has subtasks
|
||||
parent_template = self._generate_task_template(
|
||||
structure=[1],
|
||||
names=["Parent Task", "Child Task"],
|
||||
)
|
||||
product = self._generate_product(task_template=parent_template)
|
||||
|
||||
# Add the product to the sale order
|
||||
sol = self._generate_sale_order_line(sale_order=so, product=product)
|
||||
|
||||
# Set the sequence to ensure proper ordering
|
||||
visit.so_section_id.sequence = 1
|
||||
sol.sequence = 2
|
||||
|
||||
# Confirm the sale order to create tasks
|
||||
so.action_confirm()
|
||||
|
||||
# Get the created tasks
|
||||
parent_task = sol.task_id
|
||||
self.assertTrue(parent_task, "Parent task should be created")
|
||||
|
||||
# Check that the parent task has a partner_id set
|
||||
self.assertTrue(parent_task.partner_id, "Parent task should have a partner set")
|
||||
|
||||
# Check that the subtask exists
|
||||
self.assertTrue(parent_task.child_ids, "Parent task should have subtasks")
|
||||
child_task = parent_task.child_ids[0]
|
||||
|
||||
# The key test: Check that the subtask has a partner_id set
|
||||
self.assertTrue(child_task.partner_id, "Subtask should have a partner_id set")
|
||||
|
||||
# Check that the subtask has the same partner as the parent task
|
||||
self.assertEqual(child_task.partner_id, parent_task.partner_id,
|
||||
"Subtask should have the same partner as its parent task")
|
||||
|
|
|
|||
|
|
@ -127,7 +127,7 @@ class TestSalesOrder(BemadeFSMBaseTest):
|
|||
parent = self._generate_partner()
|
||||
child = self._generate_partner(parent=parent)
|
||||
for i in range(3):
|
||||
self._generate_equipment(child)
|
||||
self._generate_equipment(partner=child)
|
||||
|
||||
sale_order = self._generate_sale_order(partner=parent)
|
||||
|
||||
|
|
@ -139,11 +139,11 @@ class TestSalesOrder(BemadeFSMBaseTest):
|
|||
parent = self._generate_partner()
|
||||
child = self._generate_partner(parent=parent)
|
||||
for i in range(4):
|
||||
self._generate_equipment(child)
|
||||
self._generate_equipment(partner=child)
|
||||
|
||||
sale_order = self._generate_sale_order(partner=parent)
|
||||
|
||||
self.assertEqual(sale_order.default_equipment_ids, parent.owned_equipment_ids)
|
||||
self.assertEqual(sale_order.default_equipment_ids, self.env["fsm.equipment"])
|
||||
|
||||
def test_sale_order_resets_default_equipment_on_partner_change(self):
|
||||
partner_1 = self._generate_partner()
|
||||
|
|
@ -360,4 +360,115 @@ class TestSalesOrder(BemadeFSMBaseTest):
|
|||
}
|
||||
)
|
||||
for task in parent_task._get_all_subtasks() | parent_task:
|
||||
self.assertEqual(so.partner_shipping_id, task.partner_id)
|
||||
self.assertEqual(
|
||||
so.partner_shipping_id,
|
||||
task.partner_id,
|
||||
f"{task.name} has a different partner than the SO",
|
||||
)
|
||||
|
||||
def test_task_hierarchy_maintained_after_cancel_reconfirm(self):
|
||||
"""Test that task hierarchy and project assignments are maintained when canceling
|
||||
and reconfirming a sale order with a templated FSM product."""
|
||||
self.env.user.groups_id |= self.env.ref(
|
||||
"account.group_delivery_invoice_address"
|
||||
)
|
||||
# Create a task template with subtasks
|
||||
parent_template = self._generate_task_template(
|
||||
structure=[2], # Two subtasks
|
||||
names=["Main Service", "Subtask"],
|
||||
planned_hours=8,
|
||||
)
|
||||
|
||||
# Create FSM product with template
|
||||
product = self._generate_product(task_template=parent_template)
|
||||
|
||||
# Create and confirm sale order
|
||||
partner = self._generate_partner()
|
||||
partner_2 = self._generate_partner(parent=partner, company_type="person")
|
||||
self.assertEqual(partner_2.commercial_partner_id, partner)
|
||||
so = self._generate_sale_order(partner=partner)
|
||||
sol = self._generate_sale_order_line(so, product=product)
|
||||
so.action_confirm()
|
||||
|
||||
# Get initial tasks and verify setup
|
||||
main_task = sol.task_id
|
||||
self.assertTrue(main_task, "Main task should be created")
|
||||
self.assertTrue(main_task.project_id, "Main task should have a project")
|
||||
|
||||
subtasks = main_task.child_ids
|
||||
self.assertEqual(len(subtasks), 2, "Should have created 2 subtasks")
|
||||
self.assertEqual(so.tasks_count, 1, "Should have only 1 task on confirmation")
|
||||
|
||||
# Verify initial task hierarchy
|
||||
initial_project = main_task.project_id
|
||||
for subtask in subtasks:
|
||||
self.assertEqual(
|
||||
subtask.project_id,
|
||||
initial_project,
|
||||
"Subtask should have same project as main task",
|
||||
)
|
||||
self.assertFalse(
|
||||
subtask.sale_order_id, "Subtask should not be linked to sale order"
|
||||
)
|
||||
self.assertFalse(
|
||||
subtask.sale_line_id, "Subtask should not be linked to sale order line"
|
||||
)
|
||||
|
||||
# Store initial names for comparison
|
||||
initial_subtask_names = subtasks.mapped("name")
|
||||
|
||||
original_task_names = (main_task | main_task._get_all_subtasks()).mapped("name")
|
||||
# Cancel and reconfirm the sale order
|
||||
so.with_context(disable_cancel_warning=True).action_cancel()
|
||||
|
||||
so.action_draft()
|
||||
so.write({"partner_shipping_id": partner_2.id})
|
||||
# Get new tasks
|
||||
new_main_task = sol.task_id
|
||||
self.assertEqual(
|
||||
new_main_task, main_task, "New main task should be same as old"
|
||||
)
|
||||
|
||||
new_subtasks = new_main_task.child_ids
|
||||
new_subtasks._compute_sale_line()
|
||||
self.assertEqual(
|
||||
len(new_subtasks), 2, "Should still have 2 subtasks after reconfirmation"
|
||||
)
|
||||
self.assertFalse(
|
||||
new_subtasks.sale_line_id,
|
||||
"Subtasks should not be linked to Sale Order Line",
|
||||
)
|
||||
new_task_names = (new_main_task | new_main_task._get_all_subtasks()).mapped(
|
||||
"name"
|
||||
)
|
||||
self.assertEqual(
|
||||
new_task_names, original_task_names, "New task names should be the same"
|
||||
)
|
||||
|
||||
# Verify task hierarchy is maintained
|
||||
self.assertEqual(
|
||||
new_main_task.project_id,
|
||||
initial_project,
|
||||
"New main task should have same project",
|
||||
)
|
||||
|
||||
for subtask in new_subtasks:
|
||||
self.assertEqual(
|
||||
subtask.project_id,
|
||||
initial_project,
|
||||
"New subtask should maintain same project as main task",
|
||||
)
|
||||
self.assertFalse(
|
||||
subtask.sale_order_id, "New subtask should not be linked to sale order"
|
||||
)
|
||||
self.assertFalse(
|
||||
subtask.sale_line_id,
|
||||
"New subtask should not be linked to sale order line",
|
||||
)
|
||||
|
||||
# Verify subtask names are maintained
|
||||
self.assertEqual(
|
||||
sorted(new_subtasks.mapped("name")),
|
||||
sorted(initial_subtask_names),
|
||||
"Subtask names should be maintained after reconfirmation",
|
||||
)
|
||||
|
|
|
|||
34
bemade_fsm/views/sale_order_template_views.xml
Normal file
34
bemade_fsm/views/sale_order_template_views.xml
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<record id="sale_order_template_view_form_fsm" model="ir.ui.view">
|
||||
<field name="name">sale.order.template.form.fsm</field>
|
||||
<field name="model">sale.order.template</field>
|
||||
<field name="inherit_id" ref="sale_management.sale_order_template_view_form"/>
|
||||
<field name="arch" type="xml">
|
||||
<!-- Ajouter un onglet pour les champs FSM -->
|
||||
<notebook position="inside">
|
||||
<page string="Field Service">
|
||||
<group>
|
||||
<field name="default_equipment_ids" widget="many2many_tags"/>
|
||||
<field name="site_contacts" widget="many2many_tags"/>
|
||||
<field name="work_order_contacts" widget="many2many_tags"/>
|
||||
</group>
|
||||
<!-- Section pour les templates de visite -->
|
||||
<separator string="Visit Templates"/>
|
||||
<field name="visit_template_ids">
|
||||
<tree editable="bottom">
|
||||
<field name="name"/>
|
||||
<field name="so_section_template_id"/>
|
||||
</tree>
|
||||
</field>
|
||||
</page>
|
||||
</notebook>
|
||||
|
||||
<!-- Ajouter champs équipements dans les lignes de modèle -->
|
||||
<xpath expr="//field[@name='sale_order_template_line_ids']/tree" position="inside">
|
||||
<field name="equipment_ids" widget="many2many_tags"/>
|
||||
<field name="visit_template_id"/>
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
</odoo>
|
||||
|
|
@ -69,10 +69,10 @@ class SaleOrderLine(models.Model):
|
|||
line.purchase_price_actual = \
|
||||
(stock_missing * line.purchase_price_vendor
|
||||
+ qty_from_stock * line.purchase_price) \
|
||||
/ line.product_uom_qty
|
||||
/ line.product_uom_qty if line.product_uom_qty != 0 else 0
|
||||
line.gross_profit = line.price_subtotal - (
|
||||
line.purchase_price_actual * line.product_uom_qty)
|
||||
line.gross_profit_percent = line.price_subtotal and line.gross_profit / line.price_subtotal
|
||||
line.gross_profit_percent = line.price_subtotal and line.gross_profit / line.price_subtotal if line.price_subtotal != 0 else 0
|
||||
|
||||
def _determine_missing_stock(self) -> float:
|
||||
""" Compute how much stock is missing to meet an order line's demand. In the
|
||||
|
|
|
|||
|
|
@ -33,10 +33,7 @@ class Company(models.Model):
|
|||
("specific", "Specific Vendors"),
|
||||
],
|
||||
default="all",
|
||||
help=(
|
||||
"Choose whether to apply overdue warnings to all vendors or only to "
|
||||
"specific vendors."
|
||||
),
|
||||
help="Choose whether to apply overdue warnings to all vendors or only to specific vendors.",
|
||||
)
|
||||
|
||||
warn_supplier_specific_ids = fields.Many2many(
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@
|
|||
|
||||
{
|
||||
"name": "CalDAV Synchronization",
|
||||
"version": "17.0.0.6.0",
|
||||
"version": "17.0.0.6.5",
|
||||
"license": "LGPL-3",
|
||||
"category": "Productivity",
|
||||
"summary": "Synchronize Odoo Calendar Events with CalDAV Servers",
|
||||
|
|
|
|||
|
|
@ -2,7 +2,8 @@ import uuid
|
|||
|
||||
import icalendar.cal
|
||||
|
||||
from odoo import models, api, fields
|
||||
from odoo import models, api, fields, _
|
||||
from odoo.tools.misc import _logger
|
||||
from odoo.addons.calendar.models.calendar_recurrence import MAX_RECURRENT_EVENT
|
||||
import caldav
|
||||
import logging
|
||||
|
|
@ -453,7 +454,7 @@ class CalendarEvent(models.Model):
|
|||
|
||||
def _add_event_dates(self, event_data: Dict) -> None:
|
||||
"""Add pertinent dates to event data, based on self."""
|
||||
tz = self.event_tz or self.env.user.tz
|
||||
tz = self.event_tz or self.env.user.tz or self._context.get('tz')
|
||||
event_tz = timezone(tz)
|
||||
event_data["last-modified"] = vDatetime(
|
||||
utc.localize(self.write_date).astimezone(event_tz)
|
||||
|
|
@ -610,7 +611,8 @@ class CalendarEvent(models.Model):
|
|||
owned = (
|
||||
existing_instance and existing_instance.partner_id == user.partner_id
|
||||
)
|
||||
values = self._get_values_from_ical_component(component, user)
|
||||
# Pass for_creation=True only when creating a new event
|
||||
values = self._get_values_from_ical_component(component, user, for_creation=not existing_instance)
|
||||
recurrency_vals = self._get_recurrency_values_from_ical_event(component)
|
||||
if not existing_instance:
|
||||
# If we're creating an instance and it doesn't follow the recurrence,
|
||||
|
|
@ -646,7 +648,7 @@ class CalendarEvent(models.Model):
|
|||
return synced_events
|
||||
|
||||
@api.model
|
||||
def _get_existing_instance(self, uid, recurrence_id: datetime) -> CalendarEvent:
|
||||
def _get_existing_instance(self, uid, recurrence_id: Optional[datetime]) -> CalendarEvent:
|
||||
"""Find the Odoo calendar.event record matching uid and,
|
||||
if set, recurrence_id.
|
||||
"""
|
||||
|
|
@ -685,7 +687,7 @@ class CalendarEvent(models.Model):
|
|||
|
||||
return instance or self.env["calendar.event"].search(
|
||||
[
|
||||
("caldav_uid", "=", "uid"),
|
||||
("caldav_uid", "=", uid),
|
||||
("recurrence_id", "=", False),
|
||||
]
|
||||
)
|
||||
|
|
@ -840,14 +842,15 @@ class CalendarEvent(models.Model):
|
|||
|
||||
@api.model
|
||||
def _get_values_from_ical_component(
|
||||
self, component: icalendar.cal.Component, user: User
|
||||
self, component: icalendar.cal.Component, user: User, for_creation: bool = False
|
||||
) -> Dict:
|
||||
"""Get the dictionary representing calendar.event field values from
|
||||
an iCalendar event.
|
||||
|
||||
:param component: The iCalendar component from which to extract
|
||||
values.
|
||||
:param component: The iCalendar component from which to extract values.
|
||||
:param user: The res.users record the event will belong to.
|
||||
:param for_creation: Whether these values are for creating a new event (True)
|
||||
or updating an existing one (False).
|
||||
:return: The dictionary of values to construct a calendar.event."""
|
||||
start = component.get("dtstart") and component.decoded("dtstart")
|
||||
if isinstance(start, datetime):
|
||||
|
|
@ -855,8 +858,11 @@ class CalendarEvent(models.Model):
|
|||
end = component.get("dtend") and component.decoded("dtend")
|
||||
if isinstance(end, datetime):
|
||||
end = end.astimezone(utc).replace(tzinfo=None)
|
||||
organizer = self._get_organizer_partner(component)
|
||||
|
||||
# Get attendees regardless of creation/update
|
||||
attendee_ids = self._get_attendee_partners(component, user.partner_id.email)
|
||||
|
||||
# Basic values that apply to both creation and updates
|
||||
values = {
|
||||
"name": str(component.get("summary")),
|
||||
"start": start,
|
||||
|
|
@ -868,9 +874,25 @@ class CalendarEvent(models.Model):
|
|||
"videocall_location": self._extract_component_text(component, "conference"),
|
||||
"caldav_uid": str(component.get("uid")),
|
||||
"partner_ids": [(6, 0, attendee_ids.ids)],
|
||||
"partner_id": organizer.id if organizer else user.partner_id.id,
|
||||
"user_id": user.id,
|
||||
}
|
||||
|
||||
# Only set user_id and partner_id during creation
|
||||
if for_creation:
|
||||
organizer_partner = self._get_organizer_partner(component)
|
||||
if organizer_partner:
|
||||
# Get the Odoo user ID associated with the organizer partner
|
||||
organizer = organizer_partner.user_ids[0].id if organizer_partner.user_ids else False
|
||||
values.update({
|
||||
"partner_id": organizer_partner.id,
|
||||
"user_id": organizer,
|
||||
})
|
||||
else:
|
||||
# For new events without an organizer, use the current user
|
||||
values.update({
|
||||
"partner_id": user.partner_id.id,
|
||||
"user_id": user.id,
|
||||
})
|
||||
|
||||
return values
|
||||
|
||||
@api.model
|
||||
|
|
@ -886,6 +908,13 @@ class CalendarEvent(models.Model):
|
|||
matching Odoo event belongs to.
|
||||
:return: The res.partner records who are attendees for the event."""
|
||||
attendee_emails = self._get_ical_attendee_emails(component)
|
||||
# Add organizer to attendees if present
|
||||
organizer = component.get("organizer")
|
||||
if organizer:
|
||||
organizer_email = _extract_vcal_email(organizer)
|
||||
if organizer_email not in attendee_emails:
|
||||
attendee_emails.append(organizer_email)
|
||||
# Add current user if not already in attendees
|
||||
if current_user_email not in attendee_emails:
|
||||
attendee_emails.append(current_user_email)
|
||||
existing_partners = self.env["res.partner"].search(
|
||||
|
|
@ -896,14 +925,24 @@ class CalendarEvent(models.Model):
|
|||
for email in attendee_emails
|
||||
if email not in [partner.email for partner in existing_partners]
|
||||
]
|
||||
added_partners = self.env["res.partner"].create(
|
||||
[
|
||||
{
|
||||
"name": email,
|
||||
"email": email,
|
||||
}
|
||||
for email in missing_emails
|
||||
]
|
||||
# Create new partners without triggering notifications
|
||||
added_partners = (
|
||||
self.env["res.partner"]
|
||||
.with_context(
|
||||
mail_notify_author=False, # Don't notify the author
|
||||
mail_notify_force_send=False, # Don't force send notifications
|
||||
tracking_disable=True, # Disable tracking which can trigger notifications
|
||||
no_reset_password=True, # Don't trigger password reset emails
|
||||
)
|
||||
.create(
|
||||
[
|
||||
{
|
||||
"name": email,
|
||||
"email": email,
|
||||
}
|
||||
for email in missing_emails
|
||||
]
|
||||
)
|
||||
)
|
||||
final_attendees = {}
|
||||
all_partners = existing_partners | added_partners
|
||||
|
|
@ -933,11 +972,29 @@ class CalendarEvent(models.Model):
|
|||
|
||||
organizer = component.get("organizer")
|
||||
if organizer:
|
||||
partner = self.env["res.partner"].search(
|
||||
[("email", "=", _extract_vcal_email(organizer))]
|
||||
)
|
||||
# TODO: prioritize partner with a user if there is one
|
||||
return partner[0] if partner else partner # partner[0] in case many matches
|
||||
email = _extract_vcal_email(organizer)
|
||||
_logger.info("Organizer email: %s", email)
|
||||
partner = self.env["res.partner"].search([("email", "=", email)], limit=1)
|
||||
_logger.info("Found partner: %s", partner)
|
||||
if not partner:
|
||||
# Create new partner without triggering notifications
|
||||
partner = (
|
||||
self.env["res.partner"]
|
||||
.with_context(
|
||||
mail_notify_author=False,
|
||||
mail_notify_force_send=False,
|
||||
tracking_disable=True,
|
||||
no_reset_password=True,
|
||||
)
|
||||
.create(
|
||||
{
|
||||
"name": email,
|
||||
"email": email,
|
||||
}
|
||||
)
|
||||
)
|
||||
_logger.info("Created partner: %s", partner)
|
||||
return partner
|
||||
else:
|
||||
return self.env["res.partner"]
|
||||
|
||||
|
|
|
|||
|
|
@ -15,16 +15,34 @@ class ResUsers(models.Model):
|
|||
caldav_password = fields.Char(string="CalDAV Password")
|
||||
is_caldav_enabled = fields.Boolean(compute="_compute_is_caldav_enabled", store=True)
|
||||
|
||||
@property
|
||||
def SELF_WRITEABLE_FIELDS(self):
|
||||
return super().SELF_READABLE_FIELDS + [
|
||||
"caldav_calendar_url",
|
||||
"caldav_username",
|
||||
"caldav_password",
|
||||
]
|
||||
|
||||
@api.depends("caldav_username", "caldav_password", "caldav_calendar_url")
|
||||
def _compute_is_caldav_enabled(self):
|
||||
"""This is a bit of an odd way of computing the field, but it works since any
|
||||
failed attempt to get the events from the server should mark the user as
|
||||
CalDAV being disabled. We just make sure to mute the logger in the case that
|
||||
an exception is raised because we are just computing the field, not actually
|
||||
attempting to synchronize anything."""
|
||||
"""Compute whether CalDAV is enabled for each user by validating their credentials.
|
||||
We only check if we can connect to the server and access the principal, without
|
||||
fetching any events to avoid timeouts with large calendars."""
|
||||
for rec in self:
|
||||
with mute_logger("odoo.addons.caldav_sync.models.res_users"):
|
||||
rec._get_caldav_events()
|
||||
# If any required field is empty, CalDAV is disabled
|
||||
if not (
|
||||
rec.caldav_username and rec.caldav_password and rec.caldav_calendar_url
|
||||
):
|
||||
rec.is_caldav_enabled = False
|
||||
continue
|
||||
try:
|
||||
client = rec._get_caldav_client()
|
||||
# Just try to access the principal, which is a lightweight operation
|
||||
client.principal()
|
||||
rec.is_caldav_enabled = True
|
||||
except Exception as e:
|
||||
rec.is_caldav_enabled = False
|
||||
_logger.error("Failed to validate CalDAV credentials: %s", e)
|
||||
|
||||
def _get_caldav_client(self):
|
||||
self.ensure_one()
|
||||
|
|
|
|||
|
|
@ -1,2 +1,3 @@
|
|||
from . import test_res_users
|
||||
from . import test_calendar
|
||||
from . import test_external_organizer
|
||||
|
|
|
|||
3312
caldav_sync/tests/data/Calendar.ics
Normal file
3312
caldav_sync/tests/data/Calendar.ics
Normal file
File diff suppressed because it is too large
Load diff
45
caldav_sync/tests/data/Mail Attachment.ics
Normal file
45
caldav_sync/tests/data/Mail Attachment.ics
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
BEGIN:VCALENDAR
|
||||
PRODID:-//Apple Inc.//macOS 15.3.2//EN
|
||||
VERSION:2.0
|
||||
METHOD:REQUEST
|
||||
CALSCALE:GREGORIAN
|
||||
BEGIN:VTIMEZONE
|
||||
TZID:America/Toronto
|
||||
BEGIN:DAYLIGHT
|
||||
DTSTART:20070311T020000
|
||||
RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=2SU
|
||||
TZNAME:EDT
|
||||
TZOFFSETFROM:-0500
|
||||
TZOFFSETTO:-0400
|
||||
END:DAYLIGHT
|
||||
BEGIN:STANDARD
|
||||
DTSTART:20071104T020000
|
||||
RRULE:FREQ=YEARLY;BYMONTH=11;BYDAY=1SU
|
||||
TZNAME:EST
|
||||
TZOFFSETFROM:-0400
|
||||
TZOFFSETTO:-0500
|
||||
END:STANDARD
|
||||
END:VTIMEZONE
|
||||
BEGIN:VEVENT
|
||||
CREATED:20250410T122458Z
|
||||
DTEND;TZID=America/Toronto:20250414T140000
|
||||
DTSTAMP:20250410T122459Z
|
||||
DTSTART;TZID=America/Toronto:20250414T131500
|
||||
LAST-MODIFIED:20250410T122458Z
|
||||
SEQUENCE:0
|
||||
SUMMARY:Test
|
||||
TRANSP:OPAQUE
|
||||
UID:02EEF1D6-F3D2-4EB2-AD7A-1C381A629DC5
|
||||
X-APPLE-CREATOR-IDENTITY:com.apple.calendar
|
||||
X-APPLE-CREATOR-TEAM-IDENTITY:0000000000
|
||||
BEGIN:VALARM
|
||||
ACTION:NONE
|
||||
TRIGGER;VALUE=DATE-TIME:19760401T005545Z
|
||||
END:VALARM
|
||||
ATTENDEE;PARTSTAT=NEEDS-ACTION;EMAIL=mdurepos@durpro.com;CN=Marc Durepos;CU
|
||||
TYPE=INDIVIDUAL;RSVP=TRUE:mailto:mdurepos@durpro.com
|
||||
ORGANIZER;CN=Marc Durepos;EMAIL=deploy@bemade.org:mailto:marc@bemade.org
|
||||
CLASS:PUBLIC
|
||||
END:VEVENT
|
||||
END:VCALENDAR
|
||||
|
||||
14
caldav_sync/tests/data/test_external_no_organizer.ics
Normal file
14
caldav_sync/tests/data/test_external_no_organizer.ics
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
BEGIN:VCALENDAR
|
||||
VERSION:2.0
|
||||
PRODID:-//Odoo//CalDAV Client//EN
|
||||
BEGIN:VEVENT
|
||||
UID:external-organizer-test-123
|
||||
DTSTART:20250207T143000Z
|
||||
DTEND:20250207T153000Z
|
||||
DTSTAMP:20250207T143000Z
|
||||
ATTENDEE;PARTSTAT=ACCEPTED;CN=Test User:mailto:test@example.com
|
||||
ATTENDEE;PARTSTAT=ACCEPTED;CN=Mister External:mailto:mister.external@otherdomain.com
|
||||
SUMMARY:Meeting with External Organizer
|
||||
DESCRIPTION:This is a test event with an external organizer
|
||||
END:VEVENT
|
||||
END:VCALENDAR
|
||||
14
caldav_sync/tests/data/test_external_organizer.ics
Normal file
14
caldav_sync/tests/data/test_external_organizer.ics
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
BEGIN:VCALENDAR
|
||||
VERSION:2.0
|
||||
PRODID:-//Odoo//CalDAV Client//EN
|
||||
BEGIN:VEVENT
|
||||
UID:external-organizer-test-123
|
||||
DTSTART:20250207T143000Z
|
||||
DTEND:20250207T153000Z
|
||||
DTSTAMP:20250207T143000Z
|
||||
ORGANIZER;CN=External Person:mailto:external.person@otherdomain.com
|
||||
ATTENDEE;PARTSTAT=ACCEPTED;CN=Test User:mailto:test@example.com
|
||||
SUMMARY:Meeting with External Organizer
|
||||
DESCRIPTION:This is a test event with an external organizer
|
||||
END:VEVENT
|
||||
END:VCALENDAR
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
from collections.abc import Iterable
|
||||
from odoo.tests import TransactionCase
|
||||
from odoo.tests import TransactionCase, tagged
|
||||
from odoo import Command
|
||||
from unittest.mock import patch, MagicMock, DEFAULT
|
||||
import icalendar
|
||||
|
|
@ -31,18 +31,20 @@ def _patch_caldav_with_events_from_ics(ics_paths, user, last_modified=None):
|
|||
patch("caldav.Calendar") as MockCalendar,
|
||||
):
|
||||
mock_client = MockDAVClient.return_value
|
||||
mock_calendar = MockCalendar.return_value
|
||||
mock_client.calendar = mock_calendar
|
||||
mock_calendars = {}
|
||||
|
||||
def calendar_side_effect(url):
|
||||
if url not in mock_calendars:
|
||||
mock_calendars[url] = MockCalendar()
|
||||
if url == user.caldav_calendar_url:
|
||||
return mock_calendars[url]
|
||||
raise Exception("Calendar does not exist.")
|
||||
mock_cal = MagicMock()
|
||||
mock_cal.events = MagicMock(return_value=[])
|
||||
mock_cal.event_by_uid = MagicMock()
|
||||
mock_calendars[url] = mock_cal
|
||||
return mock_calendars[url]
|
||||
|
||||
mock_calendar.side_effect = calendar_side_effect
|
||||
mock_client.calendar = calendar_side_effect
|
||||
|
||||
# Get or create the mock calendar for this user
|
||||
mock_calendar = calendar_side_effect(user.caldav_calendar_url)
|
||||
|
||||
def event_by_uid_side_effect(self, uid):
|
||||
for event in self.events():
|
||||
|
|
@ -83,17 +85,33 @@ def _patch_caldav_with_events_from_ics(ics_paths, user, last_modified=None):
|
|||
yield
|
||||
|
||||
|
||||
@tagged('post_install', '-at_install')
|
||||
class TestCalendarEvent(TransactionCase, CaldavTestCommon):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
super().setUpClass()
|
||||
cls.env["res.users"].search([])._compute_is_caldav_enabled()
|
||||
cls.user_1_url = "https://mycaldav.test.com/test1calendar"
|
||||
cls.user_1 = cls._generate_user("test1", "test1", cls.user_1_url)
|
||||
cls.user_1 = cls._generate_user(
|
||||
"test1",
|
||||
caldav_username="user1",
|
||||
caldav_password="pass1",
|
||||
caldav_url=cls.user_1_url,
|
||||
)
|
||||
cls.user_2_url = "https://mycaldav.test.com/test2calendar"
|
||||
cls.user_2 = cls._generate_user("test2", "test2", cls.user_2_url)
|
||||
cls.user_2 = cls._generate_user(
|
||||
"test2",
|
||||
caldav_username="user2",
|
||||
caldav_password="pass2",
|
||||
caldav_url=cls.user_2_url,
|
||||
)
|
||||
cls.user_3_url = "https://mycaldav.test.com/test3calendar"
|
||||
cls.user_3 = cls._generate_user("test3", "test3", cls.user_3_url)
|
||||
cls.user_3 = cls._generate_user(
|
||||
"test3",
|
||||
caldav_username="user3",
|
||||
caldav_password="pass3",
|
||||
caldav_url=cls.user_3_url,
|
||||
)
|
||||
|
||||
def test_basic_event_from_server_create(self):
|
||||
user = self.user_1
|
||||
|
|
@ -110,17 +128,29 @@ class TestCalendarEvent(TransactionCase, CaldavTestCommon):
|
|||
ics_path = _get_ics_path("basic.ics")
|
||||
with _patch_caldav_with_events_from_ics(ics_path, user):
|
||||
self.env["calendar.event"].poll_caldav_server()
|
||||
|
||||
# Verify the event was created correctly
|
||||
event = self.env["calendar.event"].search([("user_id", "=", user.id)])
|
||||
self.assertEqual(event.name, "Test")
|
||||
orig_start = event.start
|
||||
orig_stop = event.stop
|
||||
|
||||
# Now update the event with the updated ICS data
|
||||
ics_path = _get_ics_path("basic_updated.ics")
|
||||
with _patch_caldav_with_events_from_ics(
|
||||
ics_path,
|
||||
user,
|
||||
last_modified=(datetime.now(UTC)),
|
||||
):
|
||||
# Clear any caches to ensure fresh data
|
||||
self.env["calendar.event"].invalidate_model()
|
||||
self.env["calendar.event"].poll_caldav_server()
|
||||
|
||||
# Refresh the event from the database to get updated values
|
||||
event.invalidate_recordset()
|
||||
event = self.env["calendar.event"].search([("user_id", "=", user.id)])
|
||||
|
||||
# Verify the event was updated correctly
|
||||
self.assertEqual(event.name, "Test Updated")
|
||||
# This next one is just lazy avoiding the HTML stripping
|
||||
self.assertIn("Some note ...", event.description)
|
||||
|
|
|
|||
81
caldav_sync/tests/test_external_organizer.py
Normal file
81
caldav_sync/tests/test_external_organizer.py
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
from odoo.tests import TransactionCase
|
||||
from unittest.mock import patch, MagicMock
|
||||
from .common import CaldavTestCommon
|
||||
from .test_calendar import _get_ics_path, _patch_caldav_with_events_from_ics
|
||||
|
||||
|
||||
class TestExternalOrganizer(TransactionCase, CaldavTestCommon):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
super().setUpClass()
|
||||
cls.user = cls._generate_user(
|
||||
"test",
|
||||
caldav_username="test",
|
||||
caldav_password="test",
|
||||
caldav_url="https://example.com/calendar",
|
||||
)
|
||||
|
||||
def test_external_organizer_event_sync(self):
|
||||
"""Test that events with external organizers are handled correctly."""
|
||||
# Setup mock for CalDAV client with our test ICS file
|
||||
with _patch_caldav_with_events_from_ics(
|
||||
[_get_ics_path("test_external_organizer.ics")], self.user
|
||||
):
|
||||
# Ensure caldav is enabled
|
||||
self.user._compute_is_caldav_enabled()
|
||||
# Sync events from the mock server
|
||||
self.env["calendar.event"].poll_caldav_server()
|
||||
|
||||
# Find the synced event
|
||||
event = self.env["calendar.event"].search(
|
||||
[("caldav_uid", "=", "external-organizer-test-123")]
|
||||
)
|
||||
|
||||
# Verify event was created
|
||||
self.assertTrue(event, "Event should be created")
|
||||
|
||||
# Verify user_id is False for external organizer
|
||||
self.assertFalse(
|
||||
event.user_id,
|
||||
"Event with external organizer should have user_id set to False",
|
||||
)
|
||||
|
||||
# Verify the organizer's email is preserved in attendees
|
||||
external_attendee = event.attendee_ids.filtered(
|
||||
lambda a: a.email == "external.person@otherdomain.com"
|
||||
)
|
||||
self.assertTrue(
|
||||
external_attendee,
|
||||
"External organizer should be present in attendees",
|
||||
)
|
||||
# The external organizer should be in the attendees list
|
||||
self.assertTrue(
|
||||
external_attendee,
|
||||
"External organizer should be present in attendees list",
|
||||
)
|
||||
|
||||
def test_no_organizer_external_event_sync(self):
|
||||
"""Test that events without organizers are assigned to the calendar's user."""
|
||||
# Setup mock for CalDAV client with our test ICS file
|
||||
with _patch_caldav_with_events_from_ics(
|
||||
[_get_ics_path("test_external_no_organizer.ics")], self.user
|
||||
):
|
||||
self.user._compute_is_caldav_enabled()
|
||||
# Make sure that the current user is not the admin user
|
||||
self.assertNotEqual(self.user.id, self.env.ref("base.user_admin").id)
|
||||
self.env["calendar.event"].poll_caldav_server()
|
||||
|
||||
# Find the synced event
|
||||
event = self.env["calendar.event"].search(
|
||||
[("caldav_uid", "=", "external-organizer-test-123")]
|
||||
)
|
||||
|
||||
# Verify event was created
|
||||
self.assertTrue(event, "Event should be created")
|
||||
|
||||
# Verify user_id is set to the calendar's user
|
||||
self.assertEqual(
|
||||
event.user_id,
|
||||
self.user,
|
||||
"Event without organizer should have user_id set to calendar's user",
|
||||
)
|
||||
|
|
@ -1,6 +1,10 @@
|
|||
from odoo.tests import TransactionCase
|
||||
from unittest.mock import patch, MagicMock
|
||||
from .common import CaldavTestCommon
|
||||
import caldav
|
||||
import logging
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class TestUsers(TransactionCase, CaldavTestCommon):
|
||||
|
|
@ -9,24 +13,67 @@ class TestUsers(TransactionCase, CaldavTestCommon):
|
|||
super().setUpClass()
|
||||
|
||||
def test_caldav_enabled_false_without_url(self):
|
||||
user = self._generate_user("test", "test")
|
||||
# Create user with no CalDAV credentials
|
||||
user = self._generate_user("test")
|
||||
self.assertFalse(user.is_caldav_enabled)
|
||||
|
||||
def test_caldav_enabled_false_without_credentials(self):
|
||||
"""Test that is_caldav_enabled is False when any required field is missing."""
|
||||
# Test with missing URL - has username and password only
|
||||
user1 = self._generate_user(
|
||||
"test1", caldav_username="user1", caldav_password="pass1"
|
||||
)
|
||||
self.assertFalse(user1.is_caldav_enabled)
|
||||
|
||||
# Test with missing username - has password and URL only
|
||||
user2 = self._generate_user(
|
||||
"test2", caldav_password="pass2", caldav_url="https://example.com"
|
||||
)
|
||||
self.assertFalse(user2.is_caldav_enabled)
|
||||
|
||||
# Test with missing password - has username and URL only
|
||||
user3 = self._generate_user(
|
||||
"test3", caldav_username="user3", caldav_url="https://example.com"
|
||||
)
|
||||
self.assertFalse(user3.is_caldav_enabled)
|
||||
|
||||
@patch("caldav.DAVClient")
|
||||
def test_caldav_connection_succeeds_but_not_calendar(self, MockDAVClient):
|
||||
user = self._generate_user("test", "test", "https://example.com/abc123")
|
||||
# Create a mock client and mock calendar
|
||||
def test_caldav_enabled_success(self, MockDAVClient):
|
||||
"""Test that is_caldav_enabled is True when connection succeeds."""
|
||||
# Create user with name 'test' and set CalDAV credentials
|
||||
user = self._generate_user(
|
||||
"test",
|
||||
caldav_username="user",
|
||||
caldav_password="pass",
|
||||
caldav_url="https://example.com/abc123",
|
||||
)
|
||||
|
||||
# Mock successful connection
|
||||
mock_client = MockDAVClient.return_value
|
||||
mock_calendar = MagicMock()
|
||||
mock_client.calendar.return_value = mock_calendar
|
||||
mock_principal = MagicMock()
|
||||
mock_client.principal.return_value = mock_principal
|
||||
|
||||
# Mock the events method to raise an exception
|
||||
mock_calendar.events.side_effect = Exception("Failed to get events")
|
||||
# Compute should succeed and set is_caldav_enabled to True
|
||||
user._compute_is_caldav_enabled()
|
||||
self.assertTrue(user.is_caldav_enabled)
|
||||
|
||||
# An exception should not be raised because we need to continue to sync other
|
||||
# users' calendars. Instead the exception message is simply logged to error.
|
||||
@patch("caldav.DAVClient")
|
||||
def test_caldav_enabled_connection_fails(self, MockDAVClient):
|
||||
"""Test that is_caldav_enabled is False when connection fails."""
|
||||
user = self._generate_user(
|
||||
"test",
|
||||
caldav_username="user",
|
||||
caldav_password="pass",
|
||||
caldav_url="https://example.com/abc123",
|
||||
)
|
||||
|
||||
# Mock failed connection
|
||||
mock_client = MockDAVClient.return_value
|
||||
mock_client.principal.side_effect = caldav.error.AuthorizationError(
|
||||
"Invalid credentials"
|
||||
)
|
||||
|
||||
# Should handle the error gracefully and set is_caldav_enabled to False
|
||||
with self.assertLogs("odoo.addons.caldav_sync.models.res_users", "ERROR"):
|
||||
user._get_caldav_events()
|
||||
|
||||
# Ensure the is_caldav_enabled is set to False
|
||||
user._compute_is_caldav_enabled()
|
||||
self.assertFalse(user.is_caldav_enabled)
|
||||
|
|
|
|||
2
chatgpt_assistant/__init__.py
Normal file
2
chatgpt_assistant/__init__.py
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
|
||||
from . import controllers
|
||||
20
chatgpt_assistant/__manifest__.py
Normal file
20
chatgpt_assistant/__manifest__.py
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
|
||||
{
|
||||
'name': 'ChatGPT Assistant for Discuss',
|
||||
'version': '1.0',
|
||||
'author': 'Bemade Inc.',
|
||||
'category': 'Tools',
|
||||
'summary': 'Integrate ChatGPT into Odoo Discuss',
|
||||
'depends': ['mail'],
|
||||
'data': [
|
||||
# 'views/assets.xml',
|
||||
],
|
||||
'assets': {
|
||||
'web.assets_backend': [
|
||||
# '/chatgpt_assistant/static/src/js/discuss_extension.js',
|
||||
],
|
||||
},
|
||||
'installable': True,
|
||||
'application': False,
|
||||
'license': 'LGPL-3',
|
||||
}
|
||||
1
chatgpt_assistant/controllers/__init__.py
Normal file
1
chatgpt_assistant/controllers/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
from . import main
|
||||
23
chatgpt_assistant/controllers/main.py
Normal file
23
chatgpt_assistant/controllers/main.py
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
|
||||
from odoo import http
|
||||
from odoo.http import request
|
||||
import openai
|
||||
import pandas as pd
|
||||
|
||||
class DiscussChatGPT(http.Controller):
|
||||
@http.route('/discuss/chatgpt', type='json', auth='user')
|
||||
def chatgpt_respond(self, message, attachment_id=None):
|
||||
openai.api_key = request.env['ir.config_parameter'].sudo().get_param('chatgpt.api_key')
|
||||
|
||||
if attachment_id:
|
||||
attachment = request.env['ir.attachment'].sudo().browse(attachment_id)
|
||||
if attachment.mimetype == 'text/csv':
|
||||
data = pd.read_csv(attachment._full_path(attachment.store_fname))
|
||||
processed_data = data.describe().to_string()
|
||||
return {'response': f"Analyse des données : \n{processed_data}"}
|
||||
|
||||
response = openai.ChatCompletion.create(
|
||||
model="gpt-4",
|
||||
messages=[{"role": "user", "content": message}]
|
||||
)
|
||||
return {'response': response['choices'][0]['message']['content']}
|
||||
28
chatgpt_assistant/static/src/js/discuss_extension.js
Normal file
28
chatgpt_assistant/static/src/js/discuss_extension.js
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
odoo.define('chatgpt_assistant.discuss_extension', [], function (Discuss, utils) {
|
||||
"use strict";
|
||||
|
||||
const { patch } = utils;
|
||||
|
||||
patch(Discuss.prototype, 'chatgpt_assistant.discuss_extension', {
|
||||
async _onChatGPTSendMessage(event) {
|
||||
const message = this.inputValue || '';
|
||||
if (!message.trim()) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await this.env.services.rpc({
|
||||
route: '/discuss/chatgpt',
|
||||
params: { message },
|
||||
});
|
||||
|
||||
if (result.response) {
|
||||
this._insertMessage(result.response);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Erreur lors de l\'appel à ChatGPT:', error);
|
||||
this._insertMessage('Erreur : Impossible de contacter ChatGPT.');
|
||||
}
|
||||
},
|
||||
});
|
||||
});
|
||||
8
chatgpt_assistant/views/assets.xml
Normal file
8
chatgpt_assistant/views/assets.xml
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
|
||||
<odoo>
|
||||
<template id="assets_backend" name="chatgpt assistant assets" inherit_id="web.assets_backend">
|
||||
<xpath expr="." position="inside">
|
||||
<script type="text/javascript" src="/chatgpt_assistant/static/src/js/discuss_extension.js"></script>
|
||||
</xpath>
|
||||
</template>
|
||||
</odoo>
|
||||
|
|
@ -1,12 +1,29 @@
|
|||
/** @odoo-module **/
|
||||
|
||||
import {Many2OneField} from "@web/views/fields/many2one/many2one_field";
|
||||
import {Many2OneField, m2oTupleFromData} from "@web/views/fields/many2one/many2one_field";
|
||||
import {patch} from "@web/core/utils/patch";
|
||||
|
||||
patch(Many2OneField.prototype, {
|
||||
get Many2XAutocompleteProps() {
|
||||
const props = super.Many2XAutocompleteProps;
|
||||
props.quickCreate = this.openConfirmationDialog.bind(this);
|
||||
return props;
|
||||
},
|
||||
/**
|
||||
* The original Many2OneField already has a quickCreate method and an openConfirmationDialog method.
|
||||
* The quickCreate method directly updates the record, while openConfirmationDialog shows a dialog
|
||||
* before calling quickCreate.
|
||||
*
|
||||
* We just need to modify the Many2XAutocompleteProps getter to use openConfirmationDialog
|
||||
* instead of quickCreate for the quickCreate property.
|
||||
*/
|
||||
get Many2XAutocompleteProps() {
|
||||
const props = super.Many2XAutocompleteProps;
|
||||
|
||||
// If quickCreate is defined, replace it with openConfirmationDialog
|
||||
if (props.quickCreate && this.openConfirmationDialog) {
|
||||
// Store the original quickCreate function
|
||||
const originalQuickCreate = props.quickCreate;
|
||||
|
||||
// Replace with openConfirmationDialog
|
||||
props.quickCreate = (name) => this.openConfirmationDialog(name);
|
||||
}
|
||||
|
||||
return props;
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,2 +1,2 @@
|
|||
|
||||
from . import models
|
||||
from . import wizards
|
||||
|
|
@ -1,15 +1,45 @@
|
|||
|
||||
{
|
||||
'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.",
|
||||
'name': 'Customer Itch Cycle',
|
||||
'version': '17.0.1.0.0',
|
||||
'category': 'Sales',
|
||||
'summary': 'Manage customer product cycles and predict future sales',
|
||||
'description': """
|
||||
This module helps track and manage customer purchasing cycles:
|
||||
* Track product purchase cycles per customer
|
||||
* Predict next purchase dates
|
||||
* Generate opportunities based on predictions
|
||||
* Monitor delayed purchases
|
||||
""",
|
||||
'author': 'DurPro',
|
||||
'website': 'https://www.durpro.com',
|
||||
'depends': [
|
||||
'base',
|
||||
'sale',
|
||||
'sale_management',
|
||||
'crm',
|
||||
'sale_crm',
|
||||
'base_automation',
|
||||
],
|
||||
'data': [
|
||||
'security/ir.model.access.csv',
|
||||
'data/month_data.xml',
|
||||
'wizards/apply_to_child_categories.xml',
|
||||
'views/itch_cycle_product_partner_view.xml',
|
||||
'views/res_partner_view.xml',
|
||||
'views/itch_cycle_actions.xml',
|
||||
'views/itch_cycle_menu.xml',
|
||||
'views/crm_lead_view.xml',
|
||||
'views/product_category_view.xml',
|
||||
'data/ir_cron_data.xml',
|
||||
# 'data/crm_automation_data.xml',
|
||||
],
|
||||
'assets': {
|
||||
'web.assets_backend': [
|
||||
'customer_itch_cycle/static/src/js/cycle_product_partner_list_view.js',
|
||||
'customer_itch_cycle/static/src/xml/process_history_button.xml',
|
||||
],
|
||||
},
|
||||
'installable': True,
|
||||
'application': False,
|
||||
'application': True,
|
||||
'license': 'LGPL-3',
|
||||
}
|
||||
|
|
|
|||
13
customer_itch_cycle/data/ir_actions_server.xml
Normal file
13
customer_itch_cycle/data/ir_actions_server.xml
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<record id="action_reprocess_all_itch_cycles" model="ir.actions.server">
|
||||
<field name="name">Reprocess All Cycles</field>
|
||||
<field name="model_id" ref="model_itch_cycle_product_partner"/>
|
||||
<field name="binding_model_id" ref="model_itch_cycle_product_partner"/>
|
||||
<field name="binding_view_types">list,form</field>
|
||||
<field name="state">code</field>
|
||||
<field name="code">
|
||||
env['itch.cycle.product.partner'].populate_from_past_orders()
|
||||
</field>
|
||||
</record>
|
||||
</odoo>
|
||||
34
customer_itch_cycle/data/ir_cron_data.xml
Normal file
34
customer_itch_cycle/data/ir_cron_data.xml
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<data noupdate="1">
|
||||
<!-- Automated Task for Creating Opportunities -->
|
||||
<record id="ir_cron_create_opportunities" model="ir.cron">
|
||||
<field name="name">Sales Cycles: Create Opportunities</field>
|
||||
<field name="model_id" ref="model_itch_cycle_product_partner"/>
|
||||
<field name="state">code</field>
|
||||
<field name="code">model._cron_create_opportunities()</field>
|
||||
<field name="user_id" ref="base.user_root"/>
|
||||
<field name="interval_number">1</field>
|
||||
<field name="interval_type">days</field>
|
||||
<field name="numbercall">-1</field>
|
||||
<field name="doall" eval="False"/>
|
||||
<field name="priority">5</field>
|
||||
<field name="active" eval="True"/>
|
||||
</record>
|
||||
|
||||
<!-- Automated Task for Processing History -->
|
||||
<record id="ir_cron_process_history" model="ir.cron">
|
||||
<field name="name">Sales Cycles: Process History</field>
|
||||
<field name="model_id" ref="model_itch_cycle_product_partner"/>
|
||||
<field name="state">code</field>
|
||||
<field name="code">model.populate_from_past_orders()</field>
|
||||
<field name="user_id" ref="base.user_root"/>
|
||||
<field name="interval_number">1</field>
|
||||
<field name="interval_type">days</field>
|
||||
<field name="numbercall">-1</field>
|
||||
<field name="doall" eval="False"/>
|
||||
<field name="priority">10</field>
|
||||
<field name="active" eval="True"/>
|
||||
</record>
|
||||
</data>
|
||||
</odoo>
|
||||
66
customer_itch_cycle/data/month_data.xml
Normal file
66
customer_itch_cycle/data/month_data.xml
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<data noupdate="1">
|
||||
<!-- Initialize months -->
|
||||
<record id="month_01" model="itch.month">
|
||||
<field name="name">January</field>
|
||||
<field name="sequence">1</field>
|
||||
<field name="number">1</field>
|
||||
</record>
|
||||
<record id="month_02" model="itch.month">
|
||||
<field name="name">February</field>
|
||||
<field name="sequence">2</field>
|
||||
<field name="number">2</field>
|
||||
</record>
|
||||
<record id="month_03" model="itch.month">
|
||||
<field name="name">March</field>
|
||||
<field name="sequence">3</field>
|
||||
<field name="number">3</field>
|
||||
</record>
|
||||
<record id="month_04" model="itch.month">
|
||||
<field name="name">April</field>
|
||||
<field name="sequence">4</field>
|
||||
<field name="number">4</field>
|
||||
</record>
|
||||
<record id="month_05" model="itch.month">
|
||||
<field name="name">May</field>
|
||||
<field name="sequence">5</field>
|
||||
<field name="number">5</field>
|
||||
</record>
|
||||
<record id="month_06" model="itch.month">
|
||||
<field name="name">June</field>
|
||||
<field name="sequence">6</field>
|
||||
<field name="number">6</field>
|
||||
</record>
|
||||
<record id="month_07" model="itch.month">
|
||||
<field name="name">July</field>
|
||||
<field name="sequence">7</field>
|
||||
<field name="number">7</field>
|
||||
</record>
|
||||
<record id="month_08" model="itch.month">
|
||||
<field name="name">August</field>
|
||||
<field name="sequence">8</field>
|
||||
<field name="number">8</field>
|
||||
</record>
|
||||
<record id="month_09" model="itch.month">
|
||||
<field name="name">September</field>
|
||||
<field name="sequence">9</field>
|
||||
<field name="number">9</field>
|
||||
</record>
|
||||
<record id="month_10" model="itch.month">
|
||||
<field name="name">October</field>
|
||||
<field name="sequence">10</field>
|
||||
<field name="number">10</field>
|
||||
</record>
|
||||
<record id="month_11" model="itch.month">
|
||||
<field name="name">November</field>
|
||||
<field name="sequence">11</field>
|
||||
<field name="number">11</field>
|
||||
</record>
|
||||
<record id="month_12" model="itch.month">
|
||||
<field name="name">December</field>
|
||||
<field name="sequence">12</field>
|
||||
<field name="number">12</field>
|
||||
</record>
|
||||
</data>
|
||||
</odoo>
|
||||
|
|
@ -1,3 +1,5 @@
|
|||
|
||||
from . import itch_cycle_product_partner
|
||||
from . import sale_order
|
||||
from . import sale_order_line
|
||||
from . import product_category
|
||||
from . import res_partner
|
||||
from . import crm_lead
|
||||
|
|
|
|||
42
customer_itch_cycle/models/crm_lead.py
Normal file
42
customer_itch_cycle/models/crm_lead.py
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
from odoo import models, fields
|
||||
|
||||
|
||||
class CrmLead(models.Model):
|
||||
"""
|
||||
Extend CRM Lead model to add purchase cycle information.
|
||||
|
||||
Adds a link to the purchase cycle associated with the opportunity.
|
||||
This allows tracking customer purchasing patterns and forecasting
|
||||
future opportunities based on historical data.
|
||||
"""
|
||||
|
||||
_inherit = 'crm.lead'
|
||||
|
||||
itch_cycle_id = fields.Many2one(
|
||||
comodel_name='itch.cycle.product.partner',
|
||||
string='Purchase Cycle',
|
||||
ondelete='set null',
|
||||
help="""
|
||||
Linked purchase cycle for this opportunity.
|
||||
Used to track the customer's purchasing patterns.
|
||||
"""
|
||||
)
|
||||
|
||||
date_last_purchase = fields.Date(
|
||||
related='itch_cycle_id.date_last_purchase',
|
||||
string='Last Purchase Date',
|
||||
readonly=True
|
||||
)
|
||||
|
||||
cycle_duration = fields.Integer(
|
||||
related='itch_cycle_id.cycle_duration',
|
||||
string='Cycle Duration (days)',
|
||||
readonly=True
|
||||
)
|
||||
|
||||
cycle_sales = fields.One2many(
|
||||
comodel_name='sale.order.line',
|
||||
related='itch_cycle_id.sale_order_line_ids',
|
||||
string='Cycle Sales',
|
||||
readonly=True
|
||||
)
|
||||
|
|
@ -1,33 +1,903 @@
|
|||
|
||||
from datetime import timedelta, date
|
||||
import logging
|
||||
import numpy as np
|
||||
from odoo import models, fields, api
|
||||
from datetime import timedelta, datetime
|
||||
from odoo.exceptions import ValidationError, UserError
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
|
||||
class ItchCycleProductPartner(models.Model):
|
||||
_name = 'itch_cycle_product_partner'
|
||||
_description = 'Itch Cycle by Product and Partner'
|
||||
"""
|
||||
Model representing the purchase cycle between a product and a partner.
|
||||
|
||||
Tracks purchase patterns, predicts future orders, and manages related
|
||||
opportunities.
|
||||
|
||||
Attributes:
|
||||
_name (str): Model technical name
|
||||
_description (str): Model description
|
||||
_inherit (list): Inherited models
|
||||
"""
|
||||
_name = "itch.cycle.product.partner"
|
||||
_description = "Product/Partner Purchase Cycle"
|
||||
_inherit = ["mail.thread", "mail.activity.mixin"]
|
||||
|
||||
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)
|
||||
_sql_constraints = [
|
||||
(
|
||||
"positive_cycle_duration",
|
||||
"CHECK(cycle_duration_override >= 0)",
|
||||
"The forced cycle duration must be positive, zero or FALSE."
|
||||
)
|
||||
]
|
||||
|
||||
@api.depends('last_purchase_date', 'itch_cycle_duration')
|
||||
def _compute_next_follow_up_date(self):
|
||||
active = fields.Boolean(
|
||||
string="Active",
|
||||
default=True,
|
||||
help="If unchecked, this cycle will be considered archived",
|
||||
tracking=True
|
||||
)
|
||||
|
||||
partner_id = fields.Many2one(
|
||||
comodel_name="res.partner",
|
||||
string="Customer",
|
||||
help="Customer associated with this cycle",
|
||||
required=True,
|
||||
tracking=True
|
||||
)
|
||||
|
||||
partner_email = fields.Char(
|
||||
related="partner_id.email",
|
||||
string="Partner Email"
|
||||
)
|
||||
|
||||
partner_phone = fields.Char(
|
||||
related="partner_id.phone",
|
||||
string="Partner Phone"
|
||||
)
|
||||
|
||||
partner_mobile = fields.Char(
|
||||
related="partner_id.mobile",
|
||||
string="Partner Mobile"
|
||||
)
|
||||
|
||||
partner_city = fields.Char(
|
||||
related="partner_id.city",
|
||||
string="Partner City"
|
||||
)
|
||||
|
||||
partner_country_id = fields.Many2one(
|
||||
related="partner_id.country_id",
|
||||
string="Partner Country"
|
||||
)
|
||||
|
||||
product_id = fields.Many2one(
|
||||
comodel_name="product.product",
|
||||
string="Product",
|
||||
help="Product associated with this cycle",
|
||||
required=True,
|
||||
tracking=True
|
||||
)
|
||||
|
||||
product_categ_id = fields.Many2one(
|
||||
related="product_id.categ_id",
|
||||
string="Product Category"
|
||||
)
|
||||
|
||||
product_type = fields.Selection(
|
||||
related="product_id.type",
|
||||
string="Product Type"
|
||||
)
|
||||
|
||||
product_lst_price = fields.Float(
|
||||
related="product_id.lst_price",
|
||||
string="Product List Price"
|
||||
)
|
||||
|
||||
product_qty_available = fields.Float(
|
||||
related="product_id.qty_available",
|
||||
string="Product Quantity Available"
|
||||
)
|
||||
|
||||
sale_order_line_ids = fields.One2many(
|
||||
comodel_name="sale.order.line",
|
||||
inverse_name="itch_cycle_id",
|
||||
string="Sale Order Lines",
|
||||
help="Historical sales order lines for this product/partner combination"
|
||||
)
|
||||
|
||||
opportunity_ids = fields.Many2many(
|
||||
comodel_name="crm.lead",
|
||||
string="Related Opportunities",
|
||||
domain=[("type", "=", "opportunity")],
|
||||
help="Opportunities automatically generated from cycle predictions"
|
||||
)
|
||||
|
||||
opportunity_count = fields.Integer(
|
||||
string="Number of Opportunities",
|
||||
compute="_compute_opportunity_count",
|
||||
help="Count of related opportunities"
|
||||
)
|
||||
|
||||
quantity_total_ordered = fields.Float(
|
||||
string="Total Quantity Ordered",
|
||||
compute="_compute_sale_order_line_related_fields",
|
||||
help="Total quantity ordered by the customer for this product",
|
||||
store=True
|
||||
)
|
||||
|
||||
quantity_of_orders = fields.Integer(
|
||||
string="Number of Orders",
|
||||
compute="_compute_sale_order_line_related_fields",
|
||||
help="Number of orders placed by the customer for this product",
|
||||
store=True
|
||||
)
|
||||
|
||||
quantity_min_ordered = fields.Float(
|
||||
string="Minimum Quantity Ordered",
|
||||
compute="_compute_sale_order_line_related_fields",
|
||||
help="Minimum quantity ordered by the customer for this product",
|
||||
store=True
|
||||
)
|
||||
|
||||
quantity_max_ordered = fields.Float(
|
||||
string="Maximum Quantity Ordered",
|
||||
compute="_compute_sale_order_line_related_fields",
|
||||
help="Maximum quantity ordered by the customer for this product",
|
||||
store=True
|
||||
)
|
||||
|
||||
quantity_mean_ordered = fields.Float(
|
||||
string="Average Quantity Ordered",
|
||||
compute="_compute_sale_order_line_related_fields",
|
||||
help="Average quantity ordered by the customer for this product",
|
||||
store=True
|
||||
)
|
||||
|
||||
quantity_manual_override = fields.Float(
|
||||
string="Manual Quantity Override",
|
||||
help="Manually defined quantity",
|
||||
tracking=True
|
||||
)
|
||||
|
||||
quantity_planned = fields.Float(
|
||||
string="Planned Quantity",
|
||||
help="Planned quantity for the next order",
|
||||
compute="_compute_quantity_planned",
|
||||
store=True,
|
||||
tracking=True
|
||||
)
|
||||
|
||||
cycle_duration_calculated = fields.Integer(
|
||||
string="Calculated Average Cycle (days)",
|
||||
compute="_compute_average_cycle",
|
||||
help="Calculated average cycle in days",
|
||||
store=True
|
||||
)
|
||||
|
||||
cycle_duration_override = fields.Integer(
|
||||
string="Cycle Duration (forced)",
|
||||
help="Cycle duration in days (forced value)",
|
||||
default=0,
|
||||
tracking=True
|
||||
)
|
||||
|
||||
cycle_duration = fields.Integer(
|
||||
string="Average Cycle (days)",
|
||||
compute="_compute_itch_cycle_duration",
|
||||
help="Average cycle in days",
|
||||
store=True
|
||||
)
|
||||
|
||||
date_expected_evaluated = fields.Date(
|
||||
string="Next Expected Sale by Calculation",
|
||||
compute="_compute_date_expected_evaluated",
|
||||
store=True
|
||||
)
|
||||
|
||||
date_expected_override = fields.Date(
|
||||
string="Next Expected Sale Manual Override",
|
||||
help="Manually defined next expected sale date",
|
||||
tracking=True
|
||||
)
|
||||
|
||||
date_expected = fields.Date(
|
||||
string="Expected Date",
|
||||
help="Expected date for the next order",
|
||||
compute="_compute_date_expected",
|
||||
store=True,
|
||||
tracking=True
|
||||
)
|
||||
|
||||
date_next_follow_up = fields.Date(
|
||||
string="Follow-up Date",
|
||||
help="Planned follow-up date",
|
||||
compute="_compute_next_follow_up_date",
|
||||
store=True,
|
||||
readonly=False,
|
||||
tracking=True
|
||||
)
|
||||
|
||||
date_last_purchase = fields.Date(
|
||||
string="Last Purchase Date",
|
||||
compute="_compute_last_purchase_date",
|
||||
help="Date of last purchase",
|
||||
store=True
|
||||
)
|
||||
|
||||
state = fields.Selection(
|
||||
selection=[
|
||||
("new", "New"),
|
||||
("pending", "Pending"),
|
||||
("on_time", "On Time"),
|
||||
("upcoming", "Upcoming"),
|
||||
("delayed", "Delayed"),
|
||||
("critical", "Critical"),
|
||||
("archived", "Archived"),
|
||||
],
|
||||
string="State",
|
||||
help="Current cycle state",
|
||||
compute="_compute_state",
|
||||
store=True
|
||||
)
|
||||
|
||||
notes = fields.Text(
|
||||
string="Notes",
|
||||
help="Additional notes about this cycle",
|
||||
tracking=True
|
||||
)
|
||||
|
||||
deviation_percent = fields.Float(
|
||||
string="Average Deviation (%)",
|
||||
compute="_compute_deviation",
|
||||
help="Percentage deviation from average orders",
|
||||
store=True
|
||||
)
|
||||
|
||||
name = fields.Char(
|
||||
string='Name',
|
||||
compute='_compute_name',
|
||||
store=True,
|
||||
)
|
||||
|
||||
@api.depends('partner_id.name', 'product_id.name')
|
||||
def _compute_name(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)
|
||||
record.name = f"{record.partner_id.name}/{record.product_id.name}"
|
||||
|
||||
@api.depends("cycle_duration_override", "cycle_duration_calculated")
|
||||
def _compute_itch_cycle_duration(self):
|
||||
"""Compute the cycle duration.
|
||||
|
||||
If a cycle is manually defined, it is used instead.
|
||||
Otherwise, the calculated average cycle is used.
|
||||
"""
|
||||
for record in self:
|
||||
record.cycle_duration = (
|
||||
record.cycle_duration_override or
|
||||
record.cycle_duration_calculated)
|
||||
|
||||
@api.depends("quantity_manual_override", "quantity_mean_ordered")
|
||||
def _compute_quantity_planned(self):
|
||||
"""Compute the planned quantity for the next order.
|
||||
|
||||
If a quantity is manually defined, it is used.
|
||||
Otherwise, the average quantity is used.
|
||||
"""
|
||||
for record in self:
|
||||
record.quantity_planned = (
|
||||
record.quantity_manual_override or
|
||||
record.quantity_mean_ordered
|
||||
)
|
||||
|
||||
@api.depends("sale_order_line_ids")
|
||||
def _compute_last_purchase_date(self):
|
||||
"""Update the last purchase date."""
|
||||
for record in self:
|
||||
orders = record.sale_order_line_ids.mapped('order_id')
|
||||
last_order = orders.sorted(key=lambda o: o.date_order, reverse=True)
|
||||
record.date_last_purchase = (
|
||||
last_order[0].date_order if last_order else None
|
||||
)
|
||||
|
||||
@api.depends("date_expected")
|
||||
def _compute_next_follow_up_date(self):
|
||||
"""Compute the follow-up date.
|
||||
|
||||
Can be based on `next_expected_date`,
|
||||
but modifiable by the user.
|
||||
"""
|
||||
for record in self:
|
||||
record.date_next_follow_up = (
|
||||
record.date_expected - timedelta(days=7)
|
||||
if record.date_expected
|
||||
else None
|
||||
)
|
||||
|
||||
@api.depends("sale_order_line_ids", "product_id.categ_id")
|
||||
def _compute_average_cycle(self):
|
||||
"""Calculate the average cycle in days.
|
||||
|
||||
Takes into account seasonal factors
|
||||
if defined in product category.
|
||||
"""
|
||||
for record in self:
|
||||
product_category = record.product_id.categ_id
|
||||
if (product_category.seasonal_factor and
|
||||
product_category.season_months):
|
||||
active_months = list(map(
|
||||
int,
|
||||
product_category.season_months.split(',')
|
||||
))
|
||||
dates = sorted([
|
||||
line.order_id.date_order
|
||||
for line in record.sale_order_line_ids
|
||||
if line.order_id.date_order.month in active_months
|
||||
])
|
||||
else:
|
||||
record.next_follow_up_date = False
|
||||
dates = sorted(
|
||||
record.sale_order_line_ids.mapped('order_id.date_order')
|
||||
)
|
||||
|
||||
class ResPartner(models.Model):
|
||||
_inherit = 'res.partner'
|
||||
if len(dates) > 1:
|
||||
intervals = [
|
||||
(dates[i + 1] - dates[i]).days
|
||||
for i in range(len(dates) - 1)
|
||||
]
|
||||
record.cycle_duration_calculated = (
|
||||
int(np.mean(intervals)) if intervals else 0
|
||||
)
|
||||
else:
|
||||
record.cycle_duration_calculated = 0
|
||||
|
||||
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.constrains("cycle_duration_override", "date_expected_override")
|
||||
def _check_cycle_constraints(self):
|
||||
"""Validate cycle constraints.
|
||||
|
||||
Ensures forced cycle duration is positive
|
||||
and expected date is not in past.
|
||||
"""
|
||||
for record in self:
|
||||
if record.cycle_duration_override and record.cycle_duration_override < 0:
|
||||
raise ValidationError('The forced cycle duration must be positive.')
|
||||
if record.date_expected_override and record.date_expected_override < fields.Date.today():
|
||||
raise ValidationError('The forced expected date cannot be in the past.')
|
||||
|
||||
@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
|
||||
@api.constrains('product_id')
|
||||
def _check_product_category_tracked(self):
|
||||
"""Ensure cycles can only be created for products in tracked categories.
|
||||
|
||||
Raises ValidationError if product category
|
||||
is not configured for cycle tracking.
|
||||
"""
|
||||
for record in self:
|
||||
if not record.product_id.categ_id.is_cycle_tracked:
|
||||
msg = (
|
||||
f"Cannot create cycle for product '{record.product_id.name}' "
|
||||
f"because its category '{record.product_id.categ_id.name}' "
|
||||
"is not configured for cycle tracking. Enable 'Track Sales Cycle' "
|
||||
"in the product category settings first."
|
||||
)
|
||||
raise ValidationError(msg)
|
||||
|
||||
@api.depends(
|
||||
'cycle_duration',
|
||||
'date_last_purchase'
|
||||
)
|
||||
def _compute_date_expected_evaluated(self):
|
||||
"""Calculate next sale date based on average cycle and last purchase date.
|
||||
|
||||
Handles edge cases and logs warnings for invalid data.
|
||||
"""
|
||||
for record in self:
|
||||
try:
|
||||
# Check if cycle_duration is valid
|
||||
if not isinstance(record.cycle_duration, int):
|
||||
record.date_expected_evaluated = None
|
||||
_logger.warning(
|
||||
"Invalid cycle duration type for record %s: "
|
||||
"Expected int, got %s",
|
||||
record.id, type(record.cycle_duration)
|
||||
)
|
||||
continue
|
||||
|
||||
if record.cycle_duration < 0:
|
||||
record.date_expected_evaluated = None
|
||||
_logger.warning(
|
||||
"Invalid cycle duration value for record %s: "
|
||||
"Must be positive, got %s",
|
||||
record.id, record.cycle_duration
|
||||
)
|
||||
continue
|
||||
|
||||
# Check if date_last_purchase is valid
|
||||
if not isinstance(record.date_last_purchase, date):
|
||||
record.date_expected_evaluated = None
|
||||
_logger.warning(
|
||||
"Invalid last purchase date type for record %s: "
|
||||
"Expected date, got %s",
|
||||
record.id, type(record.date_last_purchase)
|
||||
)
|
||||
continue
|
||||
|
||||
# Calculate expected date
|
||||
record.date_expected_evaluated = (
|
||||
record.date_last_purchase +
|
||||
timedelta(days=record.cycle_duration)
|
||||
)
|
||||
|
||||
_logger.info(
|
||||
"Successfully computed date_expected_evaluated for record %s: "
|
||||
"Last purchase: %s, Cycle duration: %s days, "
|
||||
"Expected date: %s",
|
||||
record.id, record.date_last_purchase,
|
||||
record.cycle_duration, record.date_expected_evaluated
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
record.date_expected_evaluated = None
|
||||
_logger.error(
|
||||
"Error computing date_expected_evaluated for record %s: %s",
|
||||
record.id, str(e)
|
||||
)
|
||||
|
||||
@api.depends(
|
||||
'date_expected_evaluated',
|
||||
'date_expected_override',
|
||||
'cycle_duration',
|
||||
'date_last_purchase'
|
||||
)
|
||||
def _compute_date_expected(self):
|
||||
"""Calculate expected date considering:
|
||||
|
||||
- The override date if defined
|
||||
- The evaluated date if future
|
||||
- The last order date + cycle if a cycle is defined
|
||||
- Otherwise None
|
||||
"""
|
||||
for record in self:
|
||||
if (record.date_expected_override and
|
||||
record.date_expected_override > fields.Date.today()):
|
||||
record.date_expected = record.date_expected_override
|
||||
elif record.date_expected_evaluated:
|
||||
record.date_expected = record.date_expected_evaluated
|
||||
else:
|
||||
record.date_expected = None
|
||||
|
||||
@api.depends('date_expected', 'active', 'quantity_of_orders')
|
||||
def _compute_state(self):
|
||||
"""Determine current state based on various conditions.
|
||||
|
||||
Possible states: new, pending, on_time,
|
||||
upcoming, delayed, critical, archived.
|
||||
"""
|
||||
today = fields.Date.today()
|
||||
for record in self:
|
||||
if not record.active:
|
||||
record.state = 'archived'
|
||||
continue
|
||||
|
||||
if record.quantity_of_orders < 2:
|
||||
record.state = 'new'
|
||||
elif not record.date_expected:
|
||||
record.state = 'pending'
|
||||
elif record.date_expected < today:
|
||||
days_late = (today - record.date_expected).days
|
||||
record.state = 'critical' if days_late > 30 else 'delayed'
|
||||
elif (record.date_expected - today).days <= 7:
|
||||
record.state = 'upcoming'
|
||||
else:
|
||||
record.state = 'on_time'
|
||||
|
||||
@api.model
|
||||
def populate_from_past_orders(self):
|
||||
"""Process historical sales data to create or update product cycles.
|
||||
|
||||
Analyzes past orders to calculate
|
||||
cycle durations and expected dates.
|
||||
"""
|
||||
_logger.info("Starting historical sales data processing...")
|
||||
|
||||
sale_lines = self.env['sale.order.line'].search([
|
||||
('state', 'in', ['sale', 'done']),
|
||||
('order_id.state', 'in', ['sale', 'done']),
|
||||
('order_id.date_order', '!=', False),
|
||||
('product_id', '!=', False),
|
||||
('order_id.partner_id', '!=', False),
|
||||
('qty_delivered', '!=', 0)
|
||||
])
|
||||
|
||||
partner_product_lines = {}
|
||||
total_lines = len(sale_lines)
|
||||
|
||||
for i, line in enumerate(sale_lines, 1):
|
||||
if i % (total_lines // 10 or 1) == 0:
|
||||
progress = (i / total_lines) * 100
|
||||
_logger.info(f"Progress: {progress:.0f}%")
|
||||
|
||||
if not (line.product_id and line.order_id.partner_id and line.order_id.date_order):
|
||||
continue
|
||||
|
||||
key = (line.order_id.partner_id.id, line.product_id.id)
|
||||
if key not in partner_product_lines:
|
||||
partner_product_lines[key] = []
|
||||
partner_product_lines[key].append(line)
|
||||
|
||||
notifications = []
|
||||
processed_count = 0
|
||||
error_count = 0
|
||||
total_combinations = len(partner_product_lines)
|
||||
|
||||
for (partner_id, product_id), lines in partner_product_lines.items():
|
||||
cr = self.env.cr
|
||||
try:
|
||||
with cr.savepoint():
|
||||
if not partner_id or not product_id:
|
||||
continue
|
||||
|
||||
sorted_lines = sorted(
|
||||
lines,
|
||||
key=lambda line: line.order_id.date_order or fields.Datetime.now()
|
||||
)
|
||||
|
||||
total_quantity = sum(line.product_uom_qty for line in sorted_lines)
|
||||
mean_quantity = total_quantity / len(sorted_lines)
|
||||
last_order_date = sorted_lines[-1].order_id.date_order if sorted_lines else False
|
||||
|
||||
if len(sorted_lines) > 1:
|
||||
time_diffs = [
|
||||
(sorted_lines[i].order_id.date_order -
|
||||
sorted_lines[i-1].order_id.date_order).days
|
||||
for i in range(1, len(sorted_lines))
|
||||
if sorted_lines[i].order_id.date_order and
|
||||
sorted_lines[i-1].order_id.date_order
|
||||
]
|
||||
|
||||
if time_diffs:
|
||||
cycle_duration = sum(time_diffs) / len(time_diffs)
|
||||
if cycle_duration < 1:
|
||||
cycle_duration = 1
|
||||
else:
|
||||
cycle_duration = 30
|
||||
else:
|
||||
cycle_duration = 30
|
||||
|
||||
date_expected = last_order_date + timedelta(days=cycle_duration) if last_order_date else False
|
||||
|
||||
cycle = self.with_context(active_test=False).search([
|
||||
('partner_id', '=', partner_id),
|
||||
('product_id', '=', product_id)
|
||||
])
|
||||
|
||||
values = {
|
||||
'quantity_mean_ordered': mean_quantity,
|
||||
'quantity_total_ordered': total_quantity,
|
||||
'cycle_duration': cycle_duration,
|
||||
'sale_order_line_ids': [(6, 0, [line.id for line in sorted_lines])],
|
||||
'date_expected': date_expected,
|
||||
'date_last_purchase': last_order_date,
|
||||
'active': True,
|
||||
}
|
||||
|
||||
if cycle:
|
||||
cycle.write(values)
|
||||
else:
|
||||
values.update({
|
||||
'partner_id': partner_id,
|
||||
'product_id': product_id,
|
||||
})
|
||||
self.create(values)
|
||||
|
||||
processed_count += 1
|
||||
if processed_count % (total_combinations // 10 or 1) == 0:
|
||||
progress = (processed_count / total_combinations) * 100
|
||||
_logger.info(f"Progress: {progress:.0f}%")
|
||||
|
||||
except Exception as e:
|
||||
error_count += 1
|
||||
_logger.error(f"Error processing partner {partner_id} and product {product_id}: {str(e)}")
|
||||
notifications.append({
|
||||
'params': {
|
||||
'message': f"Error processing partner {partner_id} and product {product_id}: {str(e)}",
|
||||
'type': 'warning',
|
||||
'sticky': False
|
||||
}
|
||||
})
|
||||
continue
|
||||
|
||||
# Add final notification
|
||||
status = 'warning' if error_count > 0 else 'success'
|
||||
message = f"Processed {processed_count} combinations"
|
||||
if error_count > 0:
|
||||
message += f" with {error_count} errors"
|
||||
|
||||
notifications.append({
|
||||
'params': {
|
||||
'message': message,
|
||||
'type': status,
|
||||
'sticky': False
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
'tag': 'display_notifications',
|
||||
'params': {'notifications': notifications}
|
||||
}
|
||||
|
||||
@api.depends(
|
||||
'sale_order_line_ids'
|
||||
)
|
||||
def _compute_sale_order_line_related_fields(self):
|
||||
"""Compute statistics from related sale order lines.
|
||||
|
||||
Calculates:
|
||||
- Total quantity ordered
|
||||
- Number of orders
|
||||
- Minimum quantity ordered
|
||||
- Maximum quantity ordered
|
||||
- Average quantity ordered
|
||||
"""
|
||||
for record in self:
|
||||
quantities = record.sale_order_line_ids.mapped('product_uom_qty')
|
||||
record.quantity_of_orders = len(quantities)
|
||||
if quantities:
|
||||
record.quantity_total_ordered = sum(quantities)
|
||||
record.quantity_min_ordered = min(quantities)
|
||||
record.quantity_max_ordered = max(quantities)
|
||||
record.quantity_mean_ordered = sum(quantities) / len(quantities)
|
||||
else:
|
||||
record.quantity_total_ordered = 0
|
||||
record.quantity_min_ordered = 0
|
||||
record.quantity_max_ordered = 0
|
||||
record.quantity_mean_ordered = 0
|
||||
|
||||
@api.constrains(
|
||||
'quantity_manual_override'
|
||||
)
|
||||
def _check_quantity_manual_override(self):
|
||||
"""Validate manual quantity override.
|
||||
|
||||
Ensures manual quantity override is positive.
|
||||
Raises ValidationError if negative value
|
||||
is provided.
|
||||
"""
|
||||
for record in self:
|
||||
if record.quantity_manual_override < 0:
|
||||
raise ValidationError('The manual quantity override must be positive.')
|
||||
|
||||
@api.depends(
|
||||
'quantity_planned',
|
||||
'quantity_mean_ordered'
|
||||
)
|
||||
def _compute_deviation(self):
|
||||
"""Calculate deviation between planned and average quantity.
|
||||
|
||||
Returns percentage difference between
|
||||
planned quantity and historical average.
|
||||
"""
|
||||
for record in self:
|
||||
if record.quantity_mean_ordered and record.quantity_planned:
|
||||
record.deviation_percent = ((record.quantity_planned - record.quantity_mean_ordered) / record.quantity_mean_ordered) * 100
|
||||
else:
|
||||
record.deviation_percent = 0
|
||||
|
||||
@api.depends(
|
||||
'opportunity_ids'
|
||||
)
|
||||
def _compute_opportunity_count(self):
|
||||
"""Count related opportunities.
|
||||
|
||||
Returns number of opportunities
|
||||
linked to this cycle.
|
||||
"""
|
||||
for record in self:
|
||||
record.opportunity_count = len(record.opportunity_ids)
|
||||
|
||||
def action_view_opportunities(self):
|
||||
"""Open CRM opportunities view.
|
||||
|
||||
Displays all opportunities related
|
||||
to this cycle in pipeline view.
|
||||
"""
|
||||
self.ensure_one()
|
||||
action = self.env["ir.actions.actions"]._for_xml_id("crm.crm_lead_action_pipeline")
|
||||
action['domain'] = [('id', 'in', self.opportunity_ids.ids)]
|
||||
action['context'] = {
|
||||
'default_partner_id': self.partner_id.id,
|
||||
'default_expected_revenue': self.product_lst_price * self.quantity_mean_ordered,
|
||||
}
|
||||
return action
|
||||
|
||||
def _create_opportunity_data(self):
|
||||
"""Prepare data for opportunity creation.
|
||||
|
||||
Returns:
|
||||
dict: Dictionary containing opportunity data
|
||||
"""
|
||||
self.ensure_one()
|
||||
expected_date = self.date_expected or fields.Date.today()
|
||||
expected_revenue = self.product_lst_price * self.quantity_mean_ordered
|
||||
|
||||
return {
|
||||
'name': f"Predicted Sale: {self.product_id.name}",
|
||||
'partner_id': self.partner_id.id,
|
||||
'type': 'opportunity',
|
||||
'expected_revenue': expected_revenue,
|
||||
'date_deadline': expected_date,
|
||||
'itch_cycle_id': self.id,
|
||||
'description': f"""
|
||||
<h3>Automatically generated from sales cycle prediction</h3>
|
||||
<ul>
|
||||
<li><strong>Product:</strong> {self.product_id.name}</li>
|
||||
<li><strong>Expected Quantity:</strong> {self.quantity_mean_ordered}</li>
|
||||
<li><strong>Cycle Duration:</strong> {self.cycle_duration} days</li>
|
||||
<li><strong>Previous Order Date:</strong> {self.date_last_purchase}</li>
|
||||
</ul>
|
||||
""",
|
||||
}
|
||||
|
||||
def create_opportunity(self):
|
||||
"""Create new opportunity from cycle prediction.
|
||||
|
||||
Generates opportunity with expected revenue
|
||||
and deadline based on cycle data.
|
||||
"""
|
||||
self.ensure_one()
|
||||
|
||||
# Create opportunity using prepared data
|
||||
opportunity = self.env['crm.lead'].create(
|
||||
self._create_opportunity_data()
|
||||
)
|
||||
|
||||
# Link the opportunity to this cycle
|
||||
self.write({
|
||||
'opportunity_ids': [(4, opportunity.id)]
|
||||
})
|
||||
|
||||
return {
|
||||
'type': 'ir.actions.act_window',
|
||||
'res_model': 'crm.lead',
|
||||
'res_id': opportunity.id,
|
||||
'view_mode': 'form',
|
||||
'target': 'current',
|
||||
}
|
||||
|
||||
def action_create_opportunity(self):
|
||||
"""Create opportunity from button click.
|
||||
|
||||
Wrapper method for create_opportunity()
|
||||
to handle button actions.
|
||||
"""
|
||||
return self.create_opportunity()
|
||||
|
||||
def action_view_latest_opportunity(self):
|
||||
"""View most recent opportunity.
|
||||
|
||||
Opens form view of the latest created
|
||||
opportunity for this cycle.
|
||||
"""
|
||||
self.ensure_one()
|
||||
if not self.opportunity_ids:
|
||||
raise UserError("Aucune opportunité associée à ce cycle.")
|
||||
|
||||
latest_opportunity = self.opportunity_ids.sorted(key=lambda o: o.create_date, reverse=True)[0]
|
||||
return {
|
||||
'type': 'ir.actions.act_window',
|
||||
'res_model': 'crm.lead',
|
||||
'res_id': latest_opportunity.id,
|
||||
'view_mode': 'form',
|
||||
'target': 'current',
|
||||
}
|
||||
|
||||
def action_create_batch_opportunities(self):
|
||||
"""
|
||||
Create opportunities in batch for selected cycles.
|
||||
|
||||
Returns:
|
||||
dict: Action result to display notification
|
||||
"""
|
||||
created_count = 0
|
||||
error_count = 0
|
||||
|
||||
for cycle in self:
|
||||
try:
|
||||
# Check if there's already an open opportunity for this cycle
|
||||
existing_opportunities = cycle.opportunity_ids.filtered(
|
||||
lambda o: not o.stage_id.is_won
|
||||
)
|
||||
|
||||
if existing_opportunities:
|
||||
continue
|
||||
|
||||
# Create new opportunity using prepared data
|
||||
opportunity = self.env['crm.lead'].create(
|
||||
cycle._create_opportunity_data()
|
||||
)
|
||||
cycle.write({
|
||||
'opportunity_ids': [(4, opportunity.id)]
|
||||
})
|
||||
created_count += 1
|
||||
|
||||
except Exception as e:
|
||||
error_count += 1
|
||||
_logger.error(
|
||||
f"Error creating opportunity for cycle {cycle.id}: {str(e)}"
|
||||
)
|
||||
continue
|
||||
|
||||
return {
|
||||
'type': 'ir.actions.client',
|
||||
'tag': 'display_notification',
|
||||
'params': {
|
||||
'title': 'Batch Opportunity Creation',
|
||||
'message': f"Created {created_count} opportunities, {error_count} errors",
|
||||
'sticky': False,
|
||||
'type': 'success' if error_count == 0 else 'warning',
|
||||
}
|
||||
}
|
||||
|
||||
def _cron_create_opportunities(self):
|
||||
"""Automatically create opportunities for upcoming predicted sales.
|
||||
|
||||
This cron job will:
|
||||
- Find all active cycles with expected dates in the next 30 days
|
||||
- Create new opportunities for cycles without existing open opportunities
|
||||
- Log the results of the operation
|
||||
"""
|
||||
_logger.info("Starting automatic opportunity creation...")
|
||||
|
||||
# Find upcoming cycles in the next 30 days
|
||||
today = fields.Date.today()
|
||||
upcoming_cycles = self.search([
|
||||
('date_expected', '!=', False),
|
||||
('date_expected', '>=', today),
|
||||
('date_expected', '<=', today + timedelta(days=30)),
|
||||
('active', '=', True),
|
||||
])
|
||||
|
||||
created_count = 0
|
||||
skipped_count = 0
|
||||
error_count = 0
|
||||
|
||||
for cycle in upcoming_cycles:
|
||||
try:
|
||||
# Check if there's already an open opportunity for this cycle
|
||||
existing_opportunities = cycle.opportunity_ids.filtered(
|
||||
lambda o: not o.stage_id.is_won and
|
||||
o.date_deadline >= today
|
||||
)
|
||||
|
||||
if existing_opportunities:
|
||||
skipped_count += 1
|
||||
continue
|
||||
|
||||
# Create new opportunity using prepared data
|
||||
opportunity = self.env['crm.lead'].create(
|
||||
cycle._create_opportunity_data()
|
||||
)
|
||||
cycle.write({
|
||||
'opportunity_ids': [(4, opportunity.id)]
|
||||
})
|
||||
created_count += 1
|
||||
|
||||
except Exception as e:
|
||||
error_count += 1
|
||||
_logger.error(
|
||||
f"Error creating opportunity for cycle {cycle.id}: {str(e)}"
|
||||
)
|
||||
continue
|
||||
|
||||
# Log final results
|
||||
_logger.info(
|
||||
f"Opportunity creation completed: "
|
||||
f"{created_count} created, "
|
||||
f"{skipped_count} skipped, "
|
||||
f"{error_count} errors"
|
||||
)
|
||||
|
||||
return {
|
||||
'created_count': created_count,
|
||||
'skipped_count': skipped_count,
|
||||
'error_count': error_count,
|
||||
}
|
||||
|
|
|
|||
124
customer_itch_cycle/models/product_category.py
Normal file
124
customer_itch_cycle/models/product_category.py
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
from odoo import models, fields, api
|
||||
from odoo.exceptions import ValidationError
|
||||
import logging
|
||||
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
class Month(models.Model):
|
||||
"""
|
||||
Model to represent months for seasonal tracking.
|
||||
This allows for better organization and selection of active months
|
||||
for seasonal product categories.
|
||||
"""
|
||||
_name = 'itch.month'
|
||||
_description = 'Month for Seasonal Tracking'
|
||||
_order = 'sequence'
|
||||
|
||||
name = fields.Char(string='Name', required=True, translate=True)
|
||||
sequence = fields.Integer(string='Sequence', required=True)
|
||||
number = fields.Integer(string='Month Number', required=True)
|
||||
|
||||
_sql_constraints = [
|
||||
('unique_number', 'unique(number)', 'Month number must be unique!'),
|
||||
('number_range', 'CHECK(number >= 1 AND number <= 12)', 'Month number must be between 1 and 12!')
|
||||
]
|
||||
|
||||
|
||||
class ProductCategory(models.Model):
|
||||
"""
|
||||
Extends the product category model to add seasonal behavior functionality.
|
||||
|
||||
This is used in the sales cycle calculation to better predict next sales
|
||||
by taking into account seasonal patterns.
|
||||
"""
|
||||
_inherit = 'product.category'
|
||||
|
||||
is_cycle_tracked = fields.Boolean(
|
||||
string="Track Sales Cycle",
|
||||
help="If enabled, products in this category will be tracked by the sales cycle system",
|
||||
default=True,
|
||||
)
|
||||
|
||||
temp_is_cycle_tracked = fields.Boolean(
|
||||
string="Temporary Track Sales Cycle",
|
||||
help="Technical field to track changes in is_cycle_tracked",
|
||||
default=True,
|
||||
)
|
||||
|
||||
@api.onchange('is_cycle_tracked')
|
||||
def _onchange_is_cycle_tracked(self):
|
||||
"""Store the new value temporarily and restore the original value"""
|
||||
if self.id and self.is_cycle_tracked != self.temp_is_cycle_tracked:
|
||||
self.temp_is_cycle_tracked = self.is_cycle_tracked
|
||||
self.is_cycle_tracked = not self.is_cycle_tracked
|
||||
return {
|
||||
'warning': {
|
||||
'title': 'Confirmation Required',
|
||||
'message': 'Please use the "Apply Changes" button to change tracking status.'
|
||||
}
|
||||
}
|
||||
|
||||
def action_apply_cycle_tracked(self):
|
||||
"""Open wizard to apply cycle tracked changes"""
|
||||
self.ensure_one()
|
||||
return {
|
||||
'type': 'ir.actions.act_window',
|
||||
'name': 'Apply to Child Categories',
|
||||
'res_model': 'itch.apply.to.child.categories',
|
||||
'view_mode': 'form',
|
||||
'target': 'new',
|
||||
'context': {
|
||||
'default_category_id': self.id,
|
||||
'default_new_value': self.temp_is_cycle_tracked,
|
||||
}
|
||||
}
|
||||
|
||||
def write(self, vals):
|
||||
"""
|
||||
Override write to handle changes in is_cycle_tracked field.
|
||||
|
||||
If a category is no longer tracked, we archive all related cycles.
|
||||
"""
|
||||
if 'is_cycle_tracked' in vals and not vals['is_cycle_tracked']:
|
||||
# Find all cycles for products in this category
|
||||
cycles = self.env['itch.cycle.product.partner'].search([
|
||||
('product_id.categ_id', 'in', self.ids)
|
||||
])
|
||||
if cycles:
|
||||
# Archive the cycles
|
||||
cycles.write({
|
||||
'active': False,
|
||||
'notes': f'{fields.Date.today()}: Archived automatically - Category no longer tracked'
|
||||
})
|
||||
# Log the action
|
||||
_logger.info(
|
||||
f"Archived {len(cycles)} cycles due to category {self.name} "
|
||||
f"being marked as not tracked"
|
||||
)
|
||||
|
||||
return super().write(vals)
|
||||
|
||||
seasonal_factor = fields.Boolean(
|
||||
string="Seasonal Category",
|
||||
help="Enable this if the products in this category have seasonal sales patterns",
|
||||
default=False,
|
||||
)
|
||||
|
||||
season_months = fields.Many2many(
|
||||
'itch.month',
|
||||
string="Active Months",
|
||||
help="Select the months when this category is typically active",
|
||||
)
|
||||
|
||||
@api.constrains('season_months')
|
||||
def _check_season_months(self):
|
||||
"""
|
||||
Validate the selection of season_months field.
|
||||
|
||||
Rules:
|
||||
- Cannot be empty if seasonal_factor is True
|
||||
"""
|
||||
for record in self:
|
||||
if record.seasonal_factor and not record.season_months:
|
||||
raise ValidationError("Active months must be specified for seasonal categories")
|
||||
53
customer_itch_cycle/models/res_partner.py
Normal file
53
customer_itch_cycle/models/res_partner.py
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
from odoo import api, fields, models
|
||||
|
||||
|
||||
class ResPartner(models.Model):
|
||||
"""
|
||||
Extends the partner model to add sales cycle tracking functionality.
|
||||
"""
|
||||
_inherit = 'res.partner'
|
||||
|
||||
itch_cycle_product_ids = fields.One2many(
|
||||
comodel_name='itch.cycle.product.partner',
|
||||
inverse_name='partner_id',
|
||||
string="Product Sales Cycles",
|
||||
help="List of all product sales cycles associated with this customer",
|
||||
)
|
||||
|
||||
reorder_cycle_product_ids = fields.One2many(
|
||||
comodel_name='itch.cycle.product.partner',
|
||||
inverse_name='partner_id',
|
||||
string="Active Sales Cycles",
|
||||
domain=[('quantity_of_orders', '>', 1)],
|
||||
help="List of product sales cycles with established patterns",
|
||||
)
|
||||
|
||||
itch_next_delay = fields.Date(
|
||||
string="Next Follow-up Date",
|
||||
compute="_compute_itch_next_delay",
|
||||
store=True,
|
||||
help="Earliest follow-up date",
|
||||
)
|
||||
|
||||
@api.depends(
|
||||
'itch_cycle_product_ids',
|
||||
'itch_cycle_product_ids.date_next_follow_up'
|
||||
)
|
||||
def _compute_itch_next_delay(self):
|
||||
"""
|
||||
Compute the earliest follow-up date.
|
||||
|
||||
The date is calculated from all associated product cycles.
|
||||
"""
|
||||
for partner in self:
|
||||
cycles = partner.reorder_cycle_product_ids
|
||||
dates = cycles.mapped('date_next_follow_up')
|
||||
partner.itch_next_delay = min(dates) if dates else False
|
||||
|
||||
def action_populate_itch_cycles(self):
|
||||
"""
|
||||
Initialize or update product cycles.
|
||||
|
||||
The cycles are created from historical order data.
|
||||
"""
|
||||
self.env['itch.cycle.product.partner'].populate_from_past_orders()
|
||||
|
|
@ -1,44 +0,0 @@
|
|||
|
||||
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."
|
||||
))
|
||||
51
customer_itch_cycle/models/sale_order_line.py
Normal file
51
customer_itch_cycle/models/sale_order_line.py
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
from odoo import models, fields, api
|
||||
|
||||
|
||||
class SaleOrderLine(models.Model):
|
||||
"""
|
||||
Extends sale order line to add itch cycle tracking.
|
||||
"""
|
||||
_inherit = 'sale.order.line'
|
||||
|
||||
itch_cycle_id = fields.Many2one(
|
||||
comodel_name='itch.cycle.product.partner',
|
||||
string="Cycle")
|
||||
|
||||
stock_move_ids = fields.One2many(
|
||||
comodel_name='stock.move',
|
||||
inverse_name='sale_line_id',
|
||||
string="Movements")
|
||||
|
||||
sale_date = fields.Datetime(
|
||||
string="Date",
|
||||
related='order_id.date_order',
|
||||
store=True)
|
||||
|
||||
@api.model_create_multi
|
||||
def create(self, vals_list):
|
||||
"""
|
||||
Create sale order lines and associate them with their itch cycle.
|
||||
|
||||
If the product is cycle tracked and no existing cycle exists for the
|
||||
product/partner combination, a new cycle is created.
|
||||
"""
|
||||
lines = super().create(vals_list)
|
||||
|
||||
for line in lines:
|
||||
if (line.product_id and line.order_id and
|
||||
line.product_id.categ_id.is_cycle_tracked):
|
||||
partner_id = line.order_id.partner_id.id
|
||||
itch_cycle = self.env['itch.cycle.product.partner'].search([
|
||||
('partner_id', '=', partner_id),
|
||||
('product_id', '=', line.product_id.id)
|
||||
], limit=1)
|
||||
if not itch_cycle:
|
||||
cycle_vals = {
|
||||
'partner_id': partner_id,
|
||||
'product_id': line.product_id.id,
|
||||
}
|
||||
itch_cycle = self.env['itch.cycle.product.partner'].create(
|
||||
cycle_vals)
|
||||
line.itch_cycle_id = itch_cycle.id
|
||||
|
||||
return lines
|
||||
5
customer_itch_cycle/security/ir.model.access.csv
Normal file
5
customer_itch_cycle/security/ir.model.access.csv
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
|
||||
access_itch_cycle_product_partner,itch.cycle.product.partner access,model_itch_cycle_product_partner,base.group_user,1,1,1,1
|
||||
access_itch_month_user,itch.month access user,model_itch_month,base.group_user,1,0,0,0
|
||||
access_itch_month_manager,itch.month access manager,model_itch_month,sales_team.group_sale_manager,1,1,1,1
|
||||
access_itch_apply_to_child_categories,itch.apply.to.child.categories access,model_itch_apply_to_child_categories,base.group_user,1,1,1,1
|
||||
|
|
|
@ -0,0 +1,56 @@
|
|||
/** @odoo-module **/
|
||||
|
||||
import { registry } from "@web/core/registry";
|
||||
import { listView } from "@web/views/list/list_view";
|
||||
import { ListController } from "@web/views/list/list_controller";
|
||||
import { useService } from "@web/core/utils/hooks";
|
||||
|
||||
/**
|
||||
* Custom List Controller for Product/Partner Cycle Management
|
||||
* Extends standard list view to add history processing functionality
|
||||
*/
|
||||
class CycleProductPartnerListController extends ListController {
|
||||
setup() {
|
||||
super.setup();
|
||||
this.orm = useService("orm");
|
||||
this.notification = useService("notification");
|
||||
}
|
||||
|
||||
/**
|
||||
* Process historical sales data to update cycles
|
||||
* Shows notifications for processing status
|
||||
*/
|
||||
async ProcessHistoryButton() {
|
||||
try {
|
||||
this.notification.add("Starting history processing...", {
|
||||
type: "info",
|
||||
sticky: false,
|
||||
});
|
||||
|
||||
const result = await this.orm.call("itch.cycle.product.partner", "populate_from_past_orders", []);
|
||||
|
||||
if (result && result.tag === 'display_notifications' && result.params.notifications) {
|
||||
for (const notif of result.params.notifications) {
|
||||
this.notification.add(notif.params.message, {
|
||||
type: notif.params.type,
|
||||
sticky: notif.params.sticky,
|
||||
});
|
||||
await new Promise(resolve => setTimeout(resolve, 300));
|
||||
}
|
||||
}
|
||||
|
||||
await this.model.load();
|
||||
} catch (error) {
|
||||
this.notification.add(`An error occurred: ${error.message}`, {
|
||||
type: "danger",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Register the custom list view for cycle product partner
|
||||
registry.category("views").add("cycle_product_partner_list", {
|
||||
...listView,
|
||||
Controller: CycleProductPartnerListController,
|
||||
buttonTemplate: "customer_itch_cycle.ListButtons",
|
||||
});
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates id="template" xml:space="preserve">
|
||||
<t t-name="customer_itch_cycle.ListButtons" name="ProcessHistoryButton">
|
||||
<div>
|
||||
<button class="btn btn-primary" t-on-click="ProcessHistoryButton">
|
||||
Update from Sales History
|
||||
</button>
|
||||
</div>
|
||||
</t>
|
||||
</templates>
|
||||
51
customer_itch_cycle/views/crm_lead_view.xml
Normal file
51
customer_itch_cycle/views/crm_lead_view.xml
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<odoo>
|
||||
<!-- Add itch cycle field to CRM lead form -->
|
||||
<record id="view_crm_lead_form_inherit_itch_cycle" model="ir.ui.view">
|
||||
<field name="name">crm.lead.form.inherit.itch.cycle</field>
|
||||
<field name="model">crm.lead</field>
|
||||
<field name="inherit_id" ref="crm.crm_lead_view_form"/>
|
||||
<field name="arch" type="xml">
|
||||
<xpath expr="//field[@name='partner_id']" position="after">
|
||||
<field name="itch_cycle_id" widget="many2one"/>
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- Add itch cycle tab to CRM lead form -->
|
||||
<record id="view_crm_lead_form_inherit_itch_cycle_tab" model="ir.ui.view">
|
||||
<field name="name">crm.lead.form.inherit.itch.cycle.tab</field>
|
||||
<field name="model">crm.lead</field>
|
||||
<field name="inherit_id" ref="crm.crm_lead_view_form"/>
|
||||
<field name="arch" type="xml">
|
||||
<xpath expr="//notebook" position="inside">
|
||||
<page string="Purchase Cycle">
|
||||
<field name="itch_cycle_id" readonly="1"/>
|
||||
</page>
|
||||
<page string="Opportunities">
|
||||
<tree>
|
||||
<field name="expected_revenue"/>
|
||||
<field name="probability"/>
|
||||
<field name="date_deadline"/>
|
||||
<field name="stage_id"/>
|
||||
<field name="tag_ids"/>
|
||||
</tree>
|
||||
<field name="description" widget="html"/>
|
||||
</page>
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- Add itch cycle filter to CRM lead search -->
|
||||
<record id="view_crm_lead_search_inherit_itch_cycle" model="ir.ui.view">
|
||||
<field name="name">crm.lead.search.inherit.itch.cycle</field>
|
||||
<field name="model">crm.lead</field>
|
||||
<field name="inherit_id" ref="crm.view_crm_case_opportunities_filter"/>
|
||||
<field name="arch" type="xml">
|
||||
<xpath expr="//filter[@name='assigned_to_me']" position="after">
|
||||
<filter string="Purchase Cycle" name="itch_cycle"
|
||||
domain="[('itch_cycle_id','!=',False)]"/>
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
</odoo>
|
||||
13
customer_itch_cycle/views/itch_cycle_actions.xml
Normal file
13
customer_itch_cycle/views/itch_cycle_actions.xml
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
|
||||
<record id="action_cycle_product_partner" model="ir.actions.act_window">
|
||||
<field name="name">Product Cycle Management</field>
|
||||
<field name="res_model">itch.cycle.product.partner</field>
|
||||
<field name="view_mode">tree,form</field>
|
||||
<field name="view_id" ref="view_cycle_product_partner_tree"/>
|
||||
<field name="domain">[('state', '!=', 'new'), ('active', '=', True)]
|
||||
</field>
|
||||
</record>
|
||||
|
||||
</odoo>
|
||||
10
customer_itch_cycle/views/itch_cycle_menu.xml
Normal file
10
customer_itch_cycle/views/itch_cycle_menu.xml
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
|
||||
<menuitem id="menu_itch_cycle_product_root"
|
||||
name="Product Cycle"
|
||||
sequence="5"
|
||||
parent="sale.menu_sale_report"
|
||||
action="action_cycle_product_partner"/>
|
||||
|
||||
</odoo>
|
||||
|
|
@ -1,44 +1,191 @@
|
|||
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<odoo>
|
||||
<record id="view_itch_cycle_product_partner_tree" model="ir.ui.view">
|
||||
<record id="action_itch_cycle_product_partner" model="ir.actions.act_window">
|
||||
<field name="name">Cycles Produit/Partenaire</field>
|
||||
<field name="res_model">itch.cycle.product.partner</field>
|
||||
<field name="view_mode">tree,form</field>
|
||||
<field name="display_name">name_get</field>
|
||||
</record>
|
||||
|
||||
<record id="action_create_batch_opportunities" model="ir.actions.server">
|
||||
<field name="name">Create Opportunities</field>
|
||||
<field name="model_id" ref="customer_itch_cycle.model_itch_cycle_product_partner"/>
|
||||
<field name="state">code</field>
|
||||
<field name="code">records.action_create_batch_opportunities()</field>
|
||||
<field name="binding_model_id" ref="customer_itch_cycle.model_itch_cycle_product_partner"/>
|
||||
<field name="binding_type">action</field>
|
||||
<field name="binding_view_types">tree,form</field>
|
||||
</record>
|
||||
|
||||
<record id="view_cycle_product_partner_tree" model="ir.ui.view">
|
||||
<field name="name">itch.cycle.product.partner.tree</field>
|
||||
<field name="model">itch_cycle_product_partner</field>
|
||||
<field name="model">itch.cycle.product.partner</field>
|
||||
<field name="arch" type="xml">
|
||||
<tree>
|
||||
<tree js_class="cycle_product_partner_list" decoration-bf="state == 'delayed'" decoration-it="state == 'archived'">
|
||||
<field name="partner_id" widget="many2one_avatar"/>
|
||||
<field name="partner_city"/>
|
||||
<field name="partner_country_id" widget="country_flag" optional="hide"/>
|
||||
<field name="product_id" widget="many2one_avatar"/>
|
||||
<field name="product_categ_id" optional="hide"/>
|
||||
<field name="quantity_planned" sum="Total prévu"/>
|
||||
<field name="quantity_of_orders" sum="Total commandé"/>
|
||||
<field name="cycle_duration" widget="duration"/>
|
||||
<field name="date_last_purchase" optional="hide"/>
|
||||
<field name="date_expected" optional="show" string="Date attendue"/>
|
||||
<field name="date_next_follow_up" optional="show" string="Prochain suivi"/>
|
||||
<field name="state" widget="badge" options="{'classes': {'new': 'bg-info', 'on_time': 'bg-success', 'delayed': 'bg-danger', 'archived': 'bg-secondary'}}"/>
|
||||
<field name="active" invisible="1"/>
|
||||
</tree>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="view_itch_cycle_product_partner_search" model="ir.ui.view">
|
||||
<field name="name">itch.cycle.product.partner.search</field>
|
||||
<field name="model">itch.cycle.product.partner</field>
|
||||
<field name="arch" type="xml">
|
||||
<search>
|
||||
<field name="partner_id"/>
|
||||
<field name="product_id"/>
|
||||
<field name="last_purchase_date"/>
|
||||
<field name="itch_cycle_duration"/>
|
||||
<field name="next_follow_up_date"/>
|
||||
</tree>
|
||||
<field name="state"/>
|
||||
<separator/>
|
||||
<filter string="Archivé" name="inactive" domain="[('active', '=', False)]"/>
|
||||
<separator/>
|
||||
<filter string="Suivi cette semaine" name="follow_up_this_week"
|
||||
domain="[('date_next_follow_up', '>=', context_today().strftime('%Y-%m-%d')),
|
||||
('date_next_follow_up', '<=', (context_today() + datetime.timedelta(days=7)).strftime('%Y-%m-%d'))]"/>
|
||||
<filter string="Commande attendue cette semaine" name="expected_this_week"
|
||||
domain="[('date_expected', '>=', context_today().strftime('%Y-%m-%d')),
|
||||
('date_expected', '<=', (context_today() + datetime.timedelta(days=7)).strftime('%Y-%m-%d'))]"/>
|
||||
<group expand="0" string="Grouper par">
|
||||
<filter string="État" name="group_by_state" context="{'group_by': 'state'}"/>
|
||||
<filter string="Partenaire" name="group_by_partner" context="{'group_by': 'partner_id'}"/>
|
||||
<filter string="Ville" name="group_by_city" context="{'group_by': 'partner_id:city'}"/>
|
||||
<filter string="Pays" name="group_by_country" context="{'group_by': 'partner_id:country_id'}"/>
|
||||
<filter string="Produit" name="group_by_product" context="{'group_by': 'product_id'}"/>
|
||||
<filter string="Catégorie de produit" name="group_by_product_categ" context="{'group_by': 'product_id:categ_id'}"/>
|
||||
<filter string="Date de suivi" name="group_by_follow_up" context="{'group_by': 'date_next_follow_up:week'}"/>
|
||||
<filter string="Date attendue" name="group_by_expected" context="{'group_by': 'date_expected:week'}"/>
|
||||
</group>
|
||||
</search>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="view_itch_cycle_product_partner_form" model="ir.ui.view">
|
||||
<field name="name">itch.cycle.product.partner.form</field>
|
||||
<field name="model">itch_cycle_product_partner</field>
|
||||
<field name="model">itch.cycle.product.partner</field>
|
||||
<field name="arch" type="xml">
|
||||
<form>
|
||||
<header>
|
||||
<field name="state" widget="statusbar" statusbar_visible="new,on_time,delayed,archived"/>
|
||||
</header>
|
||||
<sheet>
|
||||
<div class="oe_button_box" name="button_box">
|
||||
<widget name="web_ribbon" title="Archivé" bg_color="bg-secondary" invisible="active"/>
|
||||
<button name="toggle_active"
|
||||
type="object"
|
||||
class="oe_stat_button"
|
||||
icon="fa-archive"
|
||||
help="Archiver/Désarchiver cet enregistrement">
|
||||
<field name="active" widget="boolean_button" options="{'terminology': 'archive'}"/>
|
||||
</button>
|
||||
<button name="action_create_opportunity"
|
||||
type="object"
|
||||
string="Créer une opportunité"
|
||||
class="oe_stat_button"
|
||||
icon="fa-plus"
|
||||
help="Créer une nouvelle opportunité pour ce partenaire et ce produit"/>
|
||||
<button name="action_view_latest_opportunity"
|
||||
type="object"
|
||||
string="Dernière opportunité"
|
||||
class="oe_stat_button"
|
||||
icon="fa-eye"
|
||||
help="Afficher la dernière opportunité créée pour ce partenaire et ce produit"/>
|
||||
</div>
|
||||
<group>
|
||||
<field name="partner_id"/>
|
||||
<field name="product_id"/>
|
||||
<field name="last_purchase_date"/>
|
||||
<field name="itch_cycle_duration"/>
|
||||
<field name="next_follow_up_date"/>
|
||||
<group string="Partenaire" col="2">
|
||||
<field name="partner_id"
|
||||
context="{'form_view_ref': 'base.view_partner_form'}"
|
||||
options="{'always_reload': true}"
|
||||
help="Partenaire associé à ce cycle"/>
|
||||
<field name="partner_email" help="Adresse e-mail principale du partenaire"/>
|
||||
<field name="partner_phone" help="Téléphone principal du partenaire"/>
|
||||
<field name="partner_mobile" help="Téléphone mobile du partenaire"/>
|
||||
<field name="partner_city" help="Ville du partenaire"/>
|
||||
<field name="partner_country_id" help="Pays du partenaire"/>
|
||||
</group>
|
||||
<group string="Produit" col="2">
|
||||
<field name="product_id"
|
||||
context="{'form_view_ref': 'product.product_normal_form_view'}"
|
||||
options="{'always_reload': true}"
|
||||
help="Produit associé à ce cycle"/>
|
||||
<field name="product_categ_id" help="Catégorie de produit"/>
|
||||
<field name="product_type" help="Type de produit (stockable, consommable, service)"/>
|
||||
<field name="product_lst_price" widget="monetary" help="Prix de vente du produit"/>
|
||||
<field name="product_qty_available" help="Quantité disponible en stock"/>
|
||||
</group>
|
||||
</group>
|
||||
<group>
|
||||
<group string="Remplacement manuel" col="2">
|
||||
<field name="cycle_duration_override" help="Remplacement manuel de la durée du cycle"/>
|
||||
<field name="quantity_manual_override" help="Remplacement manuel de la quantité prévue"/>
|
||||
<field name="date_expected_override" help="Remplacement manuel de la date attendue"/>
|
||||
</group>
|
||||
<group string="Statut" col="2">
|
||||
<field name="date_expected" readonly="1" widget="date" help="Date attendue calculée"/>
|
||||
<field name="cycle_duration" readonly="1" widget="duration" help="Durée du cycle calculée"/>
|
||||
<field name="quantity_planned" readonly="1" widget="float_time" help="Quantité prévue calculée"/>
|
||||
<field name="state" readonly="1" widget="badge" help="Statut actuel du cycle"/>
|
||||
<field name="active" invisible="1"/>
|
||||
</group>
|
||||
</group>
|
||||
<group>
|
||||
<group string="Statistiques de commande" col="2">
|
||||
<field name="quantity_of_orders" readonly="1" help="Nombre total de commandes"/>
|
||||
<field name="quantity_total_ordered" readonly="1" help="Quantité totale commandée"/>
|
||||
<field name="quantity_min_ordered" readonly="1" help="Quantité minimale commandée"/>
|
||||
<field name="quantity_max_ordered" readonly="1" help="Quantité maximale commandée"/>
|
||||
<field name="quantity_mean_ordered" readonly="1" help="Quantité moyenne commandée"/>
|
||||
</group>
|
||||
<group string="Statistiques de cycle" col="2">
|
||||
<field name="date_last_purchase" readonly="1" widget="date" help="Date de la dernière commande"/>
|
||||
<field name="date_next_follow_up" widget="date" options="{'datepicker': {'minDate': context_today().strftime('%Y-%m-%d')}}" help="Date du prochain suivi"/>
|
||||
<field name="cycle_duration_calculated" readonly="1" widget="duration" help="Durée du cycle calculée"/>
|
||||
<field name="date_expected_evaluated" readonly="1" widget="date" help="Date attendue évaluée"/>
|
||||
</group>
|
||||
</group>
|
||||
<notebook>
|
||||
<page string="Lignes de commande associées">
|
||||
<field name="sale_order_line_ids" readonly="1">
|
||||
<tree editable="bottom">
|
||||
<field name="order_id"/>
|
||||
<field name="product_id"/>
|
||||
<field name="price_unit"/>
|
||||
<field name="product_uom_qty"/>
|
||||
<field name="sale_date"/>
|
||||
<field name="discount"/>
|
||||
</tree>
|
||||
</field>
|
||||
</page>
|
||||
<page string="Opportunités associées">
|
||||
<field name="opportunity_ids" readonly="1">
|
||||
<tree>
|
||||
<field name="name"/>
|
||||
<field name="partner_id"/>
|
||||
<field name="expected_revenue"/>
|
||||
<field name="probability"/>
|
||||
<field name="stage_id"/>
|
||||
<field name="create_date"/>
|
||||
</tree>
|
||||
</field>
|
||||
</page>
|
||||
</notebook>
|
||||
</sheet>
|
||||
<div class="oe_chatter">
|
||||
<field name="message_follower_ids"/>
|
||||
<field name="activity_ids"/>
|
||||
<field name="message_ids"/>
|
||||
</div>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<menuitem id="menu_itch_cycle_product_partner" name="Itch Cycles"
|
||||
parent="sale.sale_order_menu"
|
||||
action="action_itch_cycle_product_partner"/>
|
||||
|
||||
<record id="action_itch_cycle_product_partner" model="ir.actions.act_window">
|
||||
<field name="name">Itch Cycles</field>
|
||||
<field name="res_model">itch_cycle_product_partner</field>
|
||||
<field name="view_mode">tree,form</field>
|
||||
</record>
|
||||
</odoo>
|
||||
|
|
|
|||
30
customer_itch_cycle/views/product_category_view.xml
Normal file
30
customer_itch_cycle/views/product_category_view.xml
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
<odoo>
|
||||
<record id="view_product_category_form" model="ir.ui.view">
|
||||
<field name="name">product.category.form</field>
|
||||
<field name="model">product.category</field>
|
||||
<field name="inherit_id" ref="product.product_category_form_view"/>
|
||||
<field name="arch" type="xml">
|
||||
<xpath expr="//form[1]/sheet[1]" position="inside">
|
||||
<group string="Sales Cycle">
|
||||
<group>
|
||||
<field name="is_cycle_tracked"/>
|
||||
<field name="temp_is_cycle_tracked" invisible="1"/>
|
||||
<button name="action_apply_cycle_tracked"
|
||||
string="Apply Changes"
|
||||
type="object"
|
||||
class="btn btn-primary"
|
||||
invisible="is_cycle_tracked == temp_is_cycle_tracked"/>
|
||||
<field name="seasonal_factor"
|
||||
help="Factor used to adjust sales predictions based on seasonal patterns"/>
|
||||
<field name="season_months"
|
||||
widget="many2many_tags"
|
||||
options="{'no_create': True}"
|
||||
help="Months where this product category has peak sales"
|
||||
invisible="not seasonal_factor"
|
||||
required="seasonal_factor"/>
|
||||
</group>
|
||||
</group>
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
</odoo>
|
||||
|
|
@ -1,23 +1,89 @@
|
|||
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<record id="view_partner_form_inherit_itch_cycle" model="ir.ui.view">
|
||||
<field name="name">res.partner.form.itch.cycle</field>
|
||||
<field name="model">res.partner</field>
|
||||
<field name="inherit_id" ref="base.view_partner_form"/>
|
||||
<field name="arch" type="xml">
|
||||
<sheet position="before">
|
||||
<group string="Itch Cycle Information">
|
||||
<field name="itch_next_delay" readonly="1"/>
|
||||
<field name="itch_cycle_product_ids" readonly="1">
|
||||
<notebook position="inside">
|
||||
<page string="Product Cycles" name="product_cycles">
|
||||
<field name="itch_cycle_product_ids">
|
||||
<tree>
|
||||
<field name="product_id"/>
|
||||
<field name="last_purchase_date"/>
|
||||
<field name="itch_cycle_duration"/>
|
||||
<field name="next_follow_up_date"/>
|
||||
<field name="quantity_mean_ordered"/>
|
||||
<field name="date_expected"/>
|
||||
<field name="active"/>
|
||||
</tree>
|
||||
</field>
|
||||
</group>
|
||||
</sheet>
|
||||
</page>
|
||||
</notebook>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="view_cycle_product_partner_form" model="ir.ui.view">
|
||||
<field name="name">itch.cycle.product.partner.form</field>
|
||||
<field name="model">itch.cycle.product.partner</field>
|
||||
<field name="arch" type="xml">
|
||||
<form>
|
||||
<header>
|
||||
<button name="create_opportunity"
|
||||
string="Create Opportunity"
|
||||
type="object"
|
||||
class="oe_highlight"
|
||||
invisible="not active"/>
|
||||
</header>
|
||||
<sheet>
|
||||
<div class="oe_button_box" name="button_box">
|
||||
<button name="action_view_opportunities"
|
||||
type="object"
|
||||
class="oe_stat_button"
|
||||
icon="fa-star">
|
||||
<field name="opportunity_count" widget="statinfo" string="Opportunities"/>
|
||||
</button>
|
||||
</div>
|
||||
<group>
|
||||
<group>
|
||||
<field name="partner_id"/>
|
||||
<field name="product_id"/>
|
||||
<field name="active"/>
|
||||
</group>
|
||||
<group>
|
||||
<field name="date_expected"/>
|
||||
<field name="cycle_duration"/>
|
||||
<field name="quantity_mean_ordered"/>
|
||||
<field name="quantity_total_ordered"/>
|
||||
</group>
|
||||
</group>
|
||||
<notebook>
|
||||
<page string="Opportunities" name="opportunities">
|
||||
<field name="opportunity_ids">
|
||||
<tree>
|
||||
<field name="name"/>
|
||||
<field name="date_deadline"/>
|
||||
<field name="expected_revenue"/>
|
||||
<field name="stage_id"/>
|
||||
</tree>
|
||||
</field>
|
||||
</page>
|
||||
<page string="Sales History" name="sales_history">
|
||||
<field name="sale_order_line_ids">
|
||||
<tree>
|
||||
<field name="order_id"/>
|
||||
<field name="product_uom_qty"/>
|
||||
<field name="price_unit"/>
|
||||
<field name="price_subtotal"/>
|
||||
<field name="state"/>
|
||||
</tree>
|
||||
</field>
|
||||
</page>
|
||||
</notebook>
|
||||
</sheet>
|
||||
<div class="oe_chatter">
|
||||
<field name="message_follower_ids" groups="base.group_user"/>
|
||||
<field name="activity_ids"/>
|
||||
<field name="message_ids"/>
|
||||
</div>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
</odoo>
|
||||
|
|
|
|||
1
customer_itch_cycle/wizards/__init__.py
Normal file
1
customer_itch_cycle/wizards/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
from . import apply_to_child_categories
|
||||
48
customer_itch_cycle/wizards/apply_to_child_categories.py
Normal file
48
customer_itch_cycle/wizards/apply_to_child_categories.py
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
from odoo import models, fields, api
|
||||
|
||||
class ApplyToChildCategories(models.TransientModel):
|
||||
"""
|
||||
Wizard to apply cycle tracking settings to child categories
|
||||
This wizard is shown when changing is_cycle_tracked on a product category
|
||||
"""
|
||||
_name = 'itch.apply.to.child.categories'
|
||||
_description = 'Apply Settings to Child Categories'
|
||||
|
||||
category_id = fields.Many2one('product.category', string='Category', required=True)
|
||||
new_value = fields.Boolean(string='New Tracking Value')
|
||||
apply_to_children = fields.Boolean(
|
||||
string='Apply to Child Categories',
|
||||
help='If checked, the cycle tracking settings will be applied to all child categories',
|
||||
default=True
|
||||
)
|
||||
child_count = fields.Integer(
|
||||
string='Number of Child Categories',
|
||||
compute='_compute_child_count'
|
||||
)
|
||||
|
||||
@api.depends('category_id')
|
||||
def _compute_child_count(self):
|
||||
"""Compute the number of child categories that would be affected"""
|
||||
for wizard in self:
|
||||
wizard.child_count = self.env['product.category'].search_count([
|
||||
('id', 'child_of', wizard.category_id.id),
|
||||
('id', '!=', wizard.category_id.id)
|
||||
])
|
||||
|
||||
def apply_settings(self):
|
||||
"""Apply the settings to the selected categories"""
|
||||
self.ensure_one()
|
||||
if self.apply_to_children:
|
||||
categories = self.env['product.category'].search([
|
||||
('id', 'child_of', self.category_id.id)
|
||||
])
|
||||
else:
|
||||
categories = self.category_id
|
||||
|
||||
# Apply the settings
|
||||
values = {
|
||||
'is_cycle_tracked': self.new_value,
|
||||
'temp_is_cycle_tracked': self.new_value,
|
||||
}
|
||||
categories.write(values)
|
||||
return {'type': 'ir.actions.act_window_close'}
|
||||
20
customer_itch_cycle/wizards/apply_to_child_categories.xml
Normal file
20
customer_itch_cycle/wizards/apply_to_child_categories.xml
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<record id="view_apply_to_child_categories_form" model="ir.ui.view">
|
||||
<field name="name">itch.apply.to.child.categories.form</field>
|
||||
<field name="model">itch.apply.to.child.categories</field>
|
||||
<field name="arch" type="xml">
|
||||
<form string="Apply to Child Categories">
|
||||
<group>
|
||||
<field name="category_id" invisible="1"/>
|
||||
<field name="child_count"/>
|
||||
<field name="apply_to_children"/>
|
||||
</group>
|
||||
<footer>
|
||||
<button string="Apply" name="apply_settings" type="object" class="btn-primary" data-hotkey="q"/>
|
||||
<button string="Cancel" class="btn-secondary" special="cancel" data-hotkey="z"/>
|
||||
</footer>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
</odoo>
|
||||
|
|
@ -20,14 +20,35 @@
|
|||
{
|
||||
"name": "FSM Visit Confirmation",
|
||||
"version": "17.0.0.1.0",
|
||||
"summary": "Have clients confirm tentatively booked visits",
|
||||
"summary": "Enable client feedback workflow for field service tasks",
|
||||
"description": """
|
||||
This module enhances the field service management workflow by leveraging Odoo's
|
||||
built-in rating system for client task confirmations. Key features include:
|
||||
|
||||
* Automated rating requests to work order contacts when tasks reach specific stages
|
||||
* Configurable stages with rating email templates
|
||||
* Client-facing rating interface with direct links in emails
|
||||
* Support for task approval or change requests through ratings
|
||||
* Integration with existing work order contacts from bemade_fsm
|
||||
* Automatic task state updates based on client feedback
|
||||
* Real-time feedback through website messages and notifications
|
||||
* Chatter integration for client comments
|
||||
|
||||
The module helps streamline communication between field service teams and clients
|
||||
by providing a clear feedback workflow and maintaining a record of all client
|
||||
interactions and approvals through Odoo's rating system.
|
||||
""",
|
||||
"category": "Services/Field Service",
|
||||
"author": "Bemade Inc.",
|
||||
"website": "http://www.bemade.org",
|
||||
"license": "LGPL-3",
|
||||
"depends": ["industry_fsm"],
|
||||
"data": ["data/mail_templates.xml"],
|
||||
"assets": {},
|
||||
"depends": ["industry_fsm", "bemade_fsm", "rating", "http_routing"],
|
||||
"data": [
|
||||
"data/mail_templates.xml",
|
||||
"views/project_portal_project_task_templates.xml",
|
||||
"views/project_task_type_views.xml",
|
||||
],
|
||||
"installable": True,
|
||||
"auto_install": False,
|
||||
"application": False,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1 +1 @@
|
|||
from . import portal
|
||||
from . import main
|
||||
|
|
|
|||
270
fsm_visit_confirmation/controllers/main.py
Normal file
270
fsm_visit_confirmation/controllers/main.py
Normal file
|
|
@ -0,0 +1,270 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
|
||||
import logging
|
||||
import werkzeug
|
||||
|
||||
from odoo import http, _
|
||||
from odoo.http import request
|
||||
from odoo.addons.base.models.ir_qweb import keep_query
|
||||
from odoo.addons.portal.controllers.portal import CustomerPortal
|
||||
from odoo.exceptions import AccessError, MissingError
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class CustomerPortalExtended(CustomerPortal):
|
||||
"""Extend the customer portal controller to handle FSM visit confirmations."""
|
||||
|
||||
@http.route("/my/task/<int:task_id>", type="http", auth="public", website=True)
|
||||
def portal_my_task(self, task_id, access_token=None, **kw):
|
||||
"""Override to allow access with token for public users."""
|
||||
try:
|
||||
task = request.env["project.task"].browse(task_id)
|
||||
task.check_access_rights("read")
|
||||
task.check_access_rights("write")
|
||||
task.check_access_rule("read")
|
||||
task.check_access_rule("write")
|
||||
except AccessError:
|
||||
task = None
|
||||
values = self._get_portal_values(task, access_token=access_token, **kw)
|
||||
if not task:
|
||||
task = values.get("task", False)
|
||||
if not task:
|
||||
return self._render_error_page(
|
||||
_("Task not found or invalid token"), task=None
|
||||
)
|
||||
|
||||
# Pass the task to the original method
|
||||
result = super(CustomerPortalExtended, self).portal_my_task(
|
||||
task_id, access_token=access_token, **kw
|
||||
)
|
||||
|
||||
# If the result is a Response object (like a redirect), return it directly
|
||||
if isinstance(result, werkzeug.wrappers.Response):
|
||||
return result
|
||||
|
||||
# Otherwise, it's a rendered template, so we need to update the qcontext
|
||||
if hasattr(result, "qcontext"):
|
||||
result.qcontext.update(values)
|
||||
|
||||
return result
|
||||
|
||||
@http.route(
|
||||
"/my/fsm_confirmation/<string:action>",
|
||||
type="http",
|
||||
auth="public",
|
||||
website=True,
|
||||
)
|
||||
def fsm_confirmation_action(self, action, **kwargs):
|
||||
"""Custom landing page for FSM visit confirmations.
|
||||
|
||||
Args:
|
||||
action: Either 'approve' or 'change'
|
||||
Kwargs:
|
||||
access_token: The access token for the task
|
||||
"""
|
||||
values = self._get_portal_values(**kwargs)
|
||||
task = values.get("task", False)
|
||||
if not task:
|
||||
return self._render_error_page(
|
||||
_("Task not found or invalid token"), task=None
|
||||
)
|
||||
# Store the language in kwargs for use in redirects
|
||||
lang = values.get("lang")
|
||||
|
||||
if action not in ("approve", "change"):
|
||||
raise werkzeug.exceptions.BadRequest(_("Invalid action"))
|
||||
|
||||
# If it's an approval, update the task state directly
|
||||
if action == "approve":
|
||||
try:
|
||||
# Update task state to approved
|
||||
task.write({"state": "03_approved"})
|
||||
|
||||
# Add a message to the chatter
|
||||
task.message_post(
|
||||
body=_("Visit approved by customer"),
|
||||
message_type="comment",
|
||||
subtype_xmlid="mail.mt_note",
|
||||
)
|
||||
|
||||
# Always redirect to the task portal page with success message
|
||||
redirect_url = "/my/task/%s?%s" % (
|
||||
task.id,
|
||||
keep_query(
|
||||
"access_token",
|
||||
visit_confirmation_status="approved",
|
||||
lang=lang,
|
||||
),
|
||||
)
|
||||
return request.redirect(redirect_url)
|
||||
except Exception as e:
|
||||
_logger.exception(_("Error approving task: %s"), e)
|
||||
return self._render_error_page(
|
||||
_("Error approving task: %s") % str(e), task=task
|
||||
)
|
||||
|
||||
# For change requests, redirect to the task portal page with change request form
|
||||
redirect_url = "/my/task/%s?%s" % (
|
||||
task.id,
|
||||
keep_query(
|
||||
"access_token",
|
||||
visit_confirmation_status="change",
|
||||
lang=lang,
|
||||
),
|
||||
)
|
||||
return request.redirect(redirect_url)
|
||||
|
||||
@http.route(
|
||||
"/my/fsm_confirmation/submit_change",
|
||||
type="http",
|
||||
auth="public",
|
||||
methods=["post"],
|
||||
website=True,
|
||||
csrf=True,
|
||||
)
|
||||
def fsm_confirmation_submit_change(self, **kwargs):
|
||||
"""Handle the submission of change request form."""
|
||||
values = self._get_portal_values(**kwargs)
|
||||
|
||||
task = values.get("task", False)
|
||||
lang = values.get("lang")
|
||||
feedback = kwargs.get("feedback", "")
|
||||
if not task:
|
||||
return self._render_error_page(
|
||||
_("Task not found or invalid token"), task=None
|
||||
)
|
||||
if not feedback:
|
||||
return self._render_error_page(
|
||||
_("No feedback provided. Please try again!"), task=task
|
||||
)
|
||||
|
||||
try:
|
||||
# Update task state to changes requested
|
||||
task.write({"state": "02_changes_requested"})
|
||||
|
||||
# Add the feedback as a message to the chatter
|
||||
task.message_post(
|
||||
body=_("Changes requested by customer: %s") % feedback,
|
||||
message_type="comment",
|
||||
subtype_xmlid="mail.mt_comment",
|
||||
)
|
||||
|
||||
# Always redirect to the task portal page with success message
|
||||
redirect_url = "/my/task/%s?%s" % (
|
||||
task.id,
|
||||
keep_query(
|
||||
access_token=kwargs.get("access_token"),
|
||||
visit_confirmation_status="change_submitted",
|
||||
lang=lang,
|
||||
),
|
||||
)
|
||||
return request.redirect(redirect_url)
|
||||
except Exception as e:
|
||||
_logger.exception(_("Error submitting change request: %s"), e)
|
||||
return self._render_error_page(
|
||||
_("Error submitting change request: %s") % str(e), task=task
|
||||
)
|
||||
|
||||
def _get_task_by_token(self, token):
|
||||
"""Get a task by its access token."""
|
||||
# First try to find the task directly by access token
|
||||
task = (
|
||||
request.env["project.task"]
|
||||
.sudo()
|
||||
.search([("access_token", "=", token)], limit=1)
|
||||
)
|
||||
if task:
|
||||
return task
|
||||
|
||||
# If not found, try to find it through the rating token
|
||||
# This is for backward compatibility with existing tokens in emails
|
||||
rating = (
|
||||
request.env["rating.rating"]
|
||||
.sudo()
|
||||
.search([("access_token", "=", token)], limit=1)
|
||||
)
|
||||
if rating and rating.res_model == "project.task":
|
||||
task = request.env["project.task"].sudo().browse(rating.res_id)
|
||||
if task.exists():
|
||||
return task
|
||||
|
||||
return None
|
||||
|
||||
def _render_error_page(self, error_message, task=None, **kwargs):
|
||||
"""Render an error page."""
|
||||
values = {
|
||||
"status_code": "400",
|
||||
"status_message": error_message,
|
||||
"lang": self._get_lang(task=task, lang=kwargs.get("lang")),
|
||||
}
|
||||
|
||||
return request.render("http_routing.http_error", values)
|
||||
|
||||
def _get_portal_values(self, task=None, **kwargs):
|
||||
"""Get common values for portal templates.
|
||||
|
||||
Args:
|
||||
task: The task record if already loaded
|
||||
**kwargs: Keyword arguments from the request
|
||||
|
||||
Returns:
|
||||
Dictionary with values for template rendering
|
||||
"""
|
||||
values = {"page_name": "task"}
|
||||
if not task:
|
||||
access_token = kwargs.get("access_token")
|
||||
if access_token:
|
||||
task = self._get_task_by_token(access_token)
|
||||
if task:
|
||||
values["task"] = task
|
||||
|
||||
# Set language and update values dictionary
|
||||
values["lang"] = self._get_lang(task=task, lang=kwargs.get("lang"))
|
||||
|
||||
# Add any additional parameters from kwargs that might be needed in templates
|
||||
for key in ["visit_confirmation_status", "error", "warning", "success"]:
|
||||
if kwargs.get(key):
|
||||
values[key] = kwargs.get(key)
|
||||
|
||||
return values
|
||||
|
||||
def _get_lang(self, task=None, lang=None):
|
||||
"""Helper method to set the language in the request environment.
|
||||
|
||||
Args:
|
||||
task: The task record (optional)
|
||||
lang: Language code (e.g., 'en_US', 'fr_FR') (optional)
|
||||
|
||||
Returns:
|
||||
The language code that was set, or None if no language was set
|
||||
"""
|
||||
# If lang is not provided, try to get it from the task
|
||||
if not lang and task:
|
||||
lang_partner = (
|
||||
task.work_order_contacts
|
||||
and task.work_order_contacts[0]
|
||||
or task.partner_id
|
||||
)
|
||||
if lang_partner and lang_partner.lang:
|
||||
lang = lang_partner.lang
|
||||
|
||||
if not lang:
|
||||
return None
|
||||
|
||||
# Get the language record
|
||||
lang_code = lang.replace("_", "-")
|
||||
lang_id = (
|
||||
request.env["res.lang"]
|
||||
.sudo()
|
||||
.search(["|", ("code", "=", lang_code), ("code", "=", lang)], limit=1)
|
||||
)
|
||||
|
||||
if lang_id:
|
||||
# Set the context language
|
||||
request.env = request.env(
|
||||
context=dict(request.env.context, lang=lang_id.code)
|
||||
)
|
||||
return lang_id.code
|
||||
|
||||
return None
|
||||
|
|
@ -1,35 +0,0 @@
|
|||
from odoo.addons.portal.controllers.portal import CustomerPortal
|
||||
from odoo.http import request, route
|
||||
from odoo.exceptions import AccessError, MissingError
|
||||
|
||||
|
||||
class FsmCustomerPortal(CustomerPortal):
|
||||
@route(
|
||||
"/my/tasks/approve_booking/<int:task_id>",
|
||||
type="http",
|
||||
auth="public",
|
||||
website=True,
|
||||
)
|
||||
def portal_approve_booking(self, task_id, access_token=None):
|
||||
try:
|
||||
visit_sudo = self._document_check_access(
|
||||
"project.task", task_id, access_token=access_token
|
||||
)
|
||||
except (AccessError, MissingError):
|
||||
return request.redirect("/my")
|
||||
|
||||
visit_sudo.action_approve_booking()
|
||||
request.session["visit_confirmation_accepted"] = True
|
||||
request.redirect(f"/my/tasks/{task_id}")
|
||||
|
||||
def _task_get_page_view_values(self, task, access_token, **kwargs):
|
||||
vals = super()._task_get_page_view_values(task, access_token, **kwargs)
|
||||
if request.session.pop("visit_confirmation_accepted", False):
|
||||
vals.update(visit_confirmation_accepted=True)
|
||||
return vals
|
||||
|
||||
def _prepare_home_portal_values(self, counters):
|
||||
vals = super()._prepare_home_portal_values(counters)
|
||||
if request.session.pop("visit_confirmation_accepted", False):
|
||||
vals.update(visit_confirmation_accepted=True)
|
||||
return vals
|
||||
|
|
@ -1,47 +1,93 @@
|
|||
<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<record id="request_fsm_task_date_confirmation" model="mail.template">
|
||||
<field name="name">Field Service Visit Confirmation Request</field>
|
||||
<record id="fsm_visit_confirmation_email_template" model="mail.template">
|
||||
<field name="name">FSM Visit Confirmation</field>
|
||||
<field name="model_id" ref="project.model_project_task"/>
|
||||
<field name="subject">Please confirm the date for your upcoming Durpro service visit</field>
|
||||
<field name="email_from">{{ object.company_id.email_formatted }}</field>
|
||||
<field name="body_html">
|
||||
<table class="table table-striped">
|
||||
<tbody>
|
||||
<field name="subject">Service Visit Confirmation: {{ object.name }}</field>
|
||||
<field name="email_from">{{ user.email_formatted }}</field>
|
||||
<field name="partner_to">{{ object.work_order_contacts and object.work_order_contacts[0].id or object.partner_id.id }}</field>
|
||||
<field name="description">Send a visit confirmation email to the customer</field>
|
||||
<field name="body_html" type="html">
|
||||
<div>
|
||||
<div style="margin-bottom: 16px;">
|
||||
Dear <t t-out="object.work_order_contacts and object.work_order_contacts[0].name or object.partner_id.name"/>
|
||||
,
|
||||
</div>
|
||||
<div style="margin-bottom: 16px;">
|
||||
We would like to confirm the details of your upcoming service visit:
|
||||
</div>
|
||||
<table style="width:100%;border-spacing:0;border:1px solid #e7e7e7;">
|
||||
<tr>
|
||||
<td scope="row">Summary</td>
|
||||
<td>{{ object.name }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td scope="row">Technician Arrival</td>
|
||||
<td>{{ object.planned_date_begin }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td scope="row">Intervention address</td>
|
||||
<td>
|
||||
<span t-esc="object.partner_id.street"/><br/>
|
||||
<span t-if="object.partner_id.street2" t-esc="object.partner_id.street2"/><br/>
|
||||
<span t-esc="object.partner_id.city"/><br/>
|
||||
<span t-esc="object.partner_id.state"/><br/>
|
||||
<span t-esc="object.partner_id.zip"/>
|
||||
<td style="padding:10px;background-color:#f8f9fa;border-bottom:1px solid #e7e7e7;">Task Summary</td>
|
||||
<td style="padding:10px;border-bottom:1px solid #e7e7e7;">
|
||||
<t t-out="object.name"/>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td scope="row">
|
||||
Assigned Technician(s):
|
||||
<td style="padding:10px;background-color:#f8f9fa;border-bottom:1px solid #e7e7e7;">Technician Arrival</td>
|
||||
<td style="padding:10px;border-bottom:1px solid #e7e7e7;">
|
||||
<t t-set="partner" t-value="object.work_order_contacts and object.work_order_contacts[0] or object.partner_id"/>
|
||||
<t t-set="partner_tz" t-value="user.tz or object.company_id.partner_id.tz or 'America/Toronto'"/>
|
||||
<span t-field="object.planned_date_begin" t-options="{'widget': 'datetime', 'timezone': partner_tz}"/>
|
||||
<div style="font-size: 12px; color: #666;">
|
||||
(Timezone: <t t-out="partner_tz"/>
|
||||
)
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<t t-foreach="object.user_ids" t-as="technician">
|
||||
<t t-esc="technician.name"/><br/>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:10px;background-color:#f8f9fa;border-bottom:1px solid #e7e7e7;">Location</td>
|
||||
<td style="padding:10px;border-bottom:1px solid #e7e7e7;">
|
||||
<t t-out="object.partner_id.street"/>
|
||||
<br/>
|
||||
<t t-if="object.partner_id.street2">
|
||||
<t t-out="object.partner_id.street2"/>
|
||||
<br/>
|
||||
</t>
|
||||
<t t-out="object.partner_id.city"/>
|
||||
, <t t-out="object.partner_id.state_id.code"/>
|
||||
<t t-out="object.partner_id.zip"/>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:10px;background-color:#f8f9fa;">Assigned Technician(s)</td>
|
||||
<td style="padding:10px;">
|
||||
<t t-foreach="object.user_ids" t-as="user">
|
||||
<t t-out="user.name"/>
|
||||
<t t-if="not user_last">
|
||||
<br/>
|
||||
</t>
|
||||
</t>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<p>To confirm this visit, <a href="{{ user.company_id.website }}/my/tasks/approve_booking/?task_id={{ object.id }}&access_token={{ object.access_token}}">click here</a>.</p>
|
||||
<p>If you would like to propose another time for this visit, please reply to this email.</p>
|
||||
<p>Best regards,</p>
|
||||
<p>The {{ object.company_id.name }} service management team.</p>
|
||||
</table>
|
||||
<div style="margin: 16px 0px 16px 0px;">
|
||||
Please take a moment to confirm this visit by clicking one of the following options:
|
||||
</div>
|
||||
<table style="width:100%;border-spacing:0;">
|
||||
<tr>
|
||||
<td style="padding:10px;text-align:center;">
|
||||
<t t-set="base_url" t-value="object.get_base_url()"/>
|
||||
<t t-set="lang" t-value="object.work_order_contacts and object.work_order_contacts[0].lang or object.partner_id.lang or 'en_US'"/>
|
||||
<a t-att-href="base_url + '/my/fsm_confirmation/approve?' + keep_query(lang=lang,access_token=object.access_token)" style="background-color:#28a745;padding:8px 16px;text-decoration:none;color:#fff;border-radius:5px;font-size:13px;">
|
||||
Approve Visit
|
||||
</a>
|
||||
</td>
|
||||
<td style="padding:10px;text-align:center;">
|
||||
<a t-att-href="base_url + '/my/fsm_confirmation/change?' + keep_query(lang=lang,access_token=object.access_token)" style="background-color:#dc3545;padding:8px 16px;text-decoration:none;color:#fff;border-radius:5px;font-size:13px;">
|
||||
Request Changes
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<div style="margin-top: 16px;">
|
||||
<p>Best regards,</p>
|
||||
<p>The <t t-out="object.company_id.name"/>
|
||||
service management team</p>
|
||||
</div>
|
||||
</div>
|
||||
</field>
|
||||
<field name="lang">{{ object.work_order_contacts and object.work_order_contacts[0].lang or object.partner_id.lang }}</field>
|
||||
<field name="auto_delete" eval="False"/>
|
||||
</record>
|
||||
</odoo>
|
||||
299
fsm_visit_confirmation/i18n/fr.po
Normal file
299
fsm_visit_confirmation/i18n/fr.po
Normal file
|
|
@ -0,0 +1,299 @@
|
|||
# Translation of Odoo Server.
|
||||
# This file contains the translation of the following modules:
|
||||
# * fsm_visit_confirmation
|
||||
#
|
||||
msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: Odoo Server 17.0+e\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2025-02-27 00:31+0000\n"
|
||||
"PO-Revision-Date: 2025-02-27 00:31+0000\n"
|
||||
"Last-Translator: Marc Durepos <marc@bemade.org>\n"
|
||||
"Language-Team: French\n"
|
||||
"Language: fr\n"
|
||||
"MIME-Version: 1.0\n"
|
||||
"Content-Type: text/plain; charset=UTF-8\n"
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
"Plural-Forms: nplurals=2; plural=(n > 1);\n"
|
||||
|
||||
#. module: fsm_visit_confirmation
|
||||
#: model:mail.template,body_html:fsm_visit_confirmation.fsm_visit_confirmation_email_template
|
||||
msgid ""
|
||||
"<div>\n"
|
||||
" <div style=\"margin-bottom: 16px;\">\n"
|
||||
" Dear <t t-out=\"object.work_order_contacts and object.work_order_contacts[0].name or object.partner_id.name\"></t>\n"
|
||||
",\n"
|
||||
" </div>\n"
|
||||
" <div style=\"margin-bottom: 16px;\">\n"
|
||||
" We would like to confirm the details of your upcoming service visit:\n"
|
||||
" </div>\n"
|
||||
" <table style=\"border-style:solid;box-sizing:border-box;border-left-color:#e7e7e7;border-bottom-color:#e7e7e7;border-right-color:#e7e7e7;border-top-color:#e7e7e7;border-left-width:1px;border-bottom-width:1px;border-right-width:1px;border-top-width:1px;-webkit-border-vertical-spacing:0px;-webkit-border-horizontal-spacing:0px;border-collapse:collapse;caption-side:bottom;width:100%;border-spacing:0;border:1pxsolid#e7e7e7\" width=\"100%\">\n"
|
||||
" <tbody style=\"border-style:none;box-sizing:border-box;border-left-width:0px;border-bottom-width:0px;border-right-width:0px;border-top-width:0px\"><tr style=\"border-style:none;box-sizing:border-box;border-left-width:0px;border-bottom-width:0px;border-right-width:0px;border-top-width:0px\">\n"
|
||||
" <td style=\"border-style:solid;box-sizing:border-box;border-bottom-color:#e7e7e7;border-left-width:0px;border-bottom-width:1px;border-right-width:0px;border-top-width:0px;padding:10px;background-color:#f8f9fa;border-bottom:1pxsolid#e7e7e7\">Task Summary</td>\n"
|
||||
" <td style=\"border-style:solid;box-sizing:border-box;border-bottom-color:#e7e7e7;border-left-width:0px;border-bottom-width:1px;border-right-width:0px;border-top-width:0px;padding:10px;border-bottom:1pxsolid#e7e7e7\">\n"
|
||||
" <t t-out=\"object.name\"></t>\n"
|
||||
" </td>\n"
|
||||
" </tr>\n"
|
||||
" <tr style=\"border-style:none;box-sizing:border-box;border-left-width:0px;border-bottom-width:0px;border-right-width:0px;border-top-width:0px\">\n"
|
||||
" <td style=\"border-style:solid;box-sizing:border-box;border-bottom-color:#e7e7e7;border-left-width:0px;border-bottom-width:1px;border-right-width:0px;border-top-width:0px;padding:10px;background-color:#f8f9fa;border-bottom:1pxsolid#e7e7e7\">Technician Arrival</td>\n"
|
||||
" <td style=\"border-style:solid;box-sizing:border-box;border-bottom-color:#e7e7e7;border-left-width:0px;border-bottom-width:1px;border-right-width:0px;border-top-width:0px;padding:10px;border-bottom:1pxsolid#e7e7e7\">\n"
|
||||
" <t t-set=\"partner\" t-value=\"object.work_order_contacts and object.work_order_contacts[0] or object.partner_id\"></t>\n"
|
||||
" <t t-set=\"partner_tz\" t-value=\"user.tz or object.company_id.partner_id.tz or 'America/Toronto'\"></t>\n"
|
||||
" <span t-field=\"object.planned_date_begin\" t-options=\"{'widget': 'datetime', 'timezone': partner_tz}\"></span>\n"
|
||||
" <div style=\"font-size: 12px; color: #666;\">\n"
|
||||
" (Timezone: <t t-out=\"partner_tz\"></t>\n"
|
||||
")\n"
|
||||
" </div>\n"
|
||||
" </td>\n"
|
||||
" </tr>\n"
|
||||
" <tr style=\"border-style:none;box-sizing:border-box;border-left-width:0px;border-bottom-width:0px;border-right-width:0px;border-top-width:0px\">\n"
|
||||
" <td style=\"border-style:solid;box-sizing:border-box;border-bottom-color:#e7e7e7;border-left-width:0px;border-bottom-width:1px;border-right-width:0px;border-top-width:0px;padding:10px;background-color:#f8f9fa;border-bottom:1pxsolid#e7e7e7\">Location</td>\n"
|
||||
" <td style=\"border-style:solid;box-sizing:border-box;border-bottom-color:#e7e7e7;border-left-width:0px;border-bottom-width:1px;border-right-width:0px;border-top-width:0px;padding:10px;border-bottom:1pxsolid#e7e7e7\">\n"
|
||||
" <t t-out=\"object.partner_id.street\"></t>\n"
|
||||
" <br>\n"
|
||||
" <t t-if=\"object.partner_id.street2\">\n"
|
||||
" <t t-out=\"object.partner_id.street2\"></t>\n"
|
||||
" <br>\n"
|
||||
" </t>\n"
|
||||
" <t t-out=\"object.partner_id.city\"></t>\n"
|
||||
", <t t-out=\"object.partner_id.state_id.code\"></t>\n"
|
||||
" <t t-out=\"object.partner_id.zip\"></t>\n"
|
||||
" </td>\n"
|
||||
" </tr>\n"
|
||||
" <tr style=\"border-style:none;box-sizing:border-box;border-left-width:0px;border-bottom-width:0px;border-right-width:0px;border-top-width:0px\">\n"
|
||||
" <td style=\"border-style:none;box-sizing:border-box;border-left-width:0px;border-bottom-width:0px;border-right-width:0px;border-top-width:0px;padding:10px;background-color:#f8f9fa\">Assigned Technician(s)</td>\n"
|
||||
" <td style=\"border-style:none;box-sizing:border-box;border-left-width:0px;border-bottom-width:0px;border-right-width:0px;border-top-width:0px;padding:10px\">\n"
|
||||
" <t t-foreach=\"object.user_ids\" t-as=\"user\">\n"
|
||||
" <t t-out=\"user.name\"></t>\n"
|
||||
" <t t-if=\"not user_last\">\n"
|
||||
" <br>\n"
|
||||
" </t>\n"
|
||||
" </t>\n"
|
||||
" </td>\n"
|
||||
" </tr>\n"
|
||||
" </tbody></table>\n"
|
||||
" <div style=\"margin: 16px 0px 16px 0px;\">\n"
|
||||
" Please take a moment to confirm this visit by clicking one of the following options:\n"
|
||||
" </div>\n"
|
||||
" <table style=\"box-sizing:border-box;-webkit-border-vertical-spacing:0px;-webkit-border-horizontal-spacing:0px;border-collapse:collapse;caption-side:bottom;width:100%;border-spacing:0;\" width=\"100%\">\n"
|
||||
" <tbody style=\"border-style:none;box-sizing:border-box;border-left-width:0px;border-bottom-width:0px;border-right-width:0px;border-top-width:0px\"><tr style=\"border-style:none;box-sizing:border-box;border-left-width:0px;border-bottom-width:0px;border-right-width:0px;border-top-width:0px\">\n"
|
||||
" <td style=\"border-style:none;box-sizing:border-box;border-left-width:0px;border-bottom-width:0px;border-right-width:0px;border-top-width:0px;padding:10px;text-align:center\">\n"
|
||||
" <t t-set=\"base_url\" t-value=\"object.get_base_url()\"></t>\n"
|
||||
" <t t-set=\"lang\" t-value=\"object.work_order_contacts and object.work_order_contacts[0].lang or object.partner_id.lang or 'en_US'\"></t>\n"
|
||||
" <a t-att-href=\"base_url + '/my/fsm_confirmation/approve?access_token=' + object.access_token + '&lang=' + lang\" style=\"box-sizing:border-box;background-color:#28a745;padding:8px 16px;text-decoration:none;color:#fff;border-radius:5px;font-size:13px;\">\n"
|
||||
" Approve Visit\n"
|
||||
" </a>\n"
|
||||
" </td>\n"
|
||||
" <td style=\"border-style:none;box-sizing:border-box;border-left-width:0px;border-bottom-width:0px;border-right-width:0px;border-top-width:0px;padding:10px;text-align:center\">\n"
|
||||
" <a t-att-href=\"base_url + '/my/fsm_confirmation/change?access_token=' + object.access_token + '&lang=' + lang\" style=\"box-sizing:border-box;background-color:#dc3545;padding:8px 16px;text-decoration:none;color:#fff;border-radius:5px;font-size:13px;\">\n"
|
||||
" Request Changes\n"
|
||||
" </a>\n"
|
||||
" </td>\n"
|
||||
" </tr>\n"
|
||||
" </tbody></table>\n"
|
||||
" <div style=\"margin-top: 16px;\">\n"
|
||||
" <p style=\"margin:0px 0 16px 0;box-sizing:border-box;\">Best regards,</p>\n"
|
||||
" <p style=\"margin:0px 0 16px 0;box-sizing:border-box;\">The <t t-out=\"object.company_id.name\"></t>\n"
|
||||
" service management team</p>\n"
|
||||
" </div>\n"
|
||||
" </div>\n"
|
||||
" "
|
||||
msgstr ""
|
||||
"<div>\n"
|
||||
" <div style=\"margin-bottom: 16px;\">\n"
|
||||
" Cher/chère<t t-out=\"object.work_order_contacts and object.work_order_contacts[0].name or object.partner_id.name\"></t>\n"
|
||||
",\n"
|
||||
" </div>\n"
|
||||
" <div style=\"margin-bottom: 16px;\">\n"
|
||||
" Nous aimerions confirmer les détails de votre visite de service à venir.\n"
|
||||
" </div>\n"
|
||||
" <table style=\"border-style:solid;box-sizing:border-box;border-left-color:#e7e7e7;border-bottom-color:#e7e7e7;border-right-color:#e7e7e7;border-top-color:#e7e7e7;border-left-width:1px;border-bottom-width:1px;border-right-width:1px;border-top-width:1px;-webkit-border-vertical-spacing:0px;-webkit-border-horizontal-spacing:0px;border-collapse:collapse;caption-side:bottom;width:100%;border-spacing:0;border:1pxsolid#e7e7e7\" width=\"100%\">\n"
|
||||
" <tbody style=\"border-style:none;box-sizing:border-box;border-left-width:0px;border-bottom-width:0px;border-right-width:0px;border-top-width:0px\"><tr style=\"border-style:none;box-sizing:border-box;border-left-width:0px;border-bottom-width:0px;border-right-width:0px;border-top-width:0px\">\n"
|
||||
" <td style=\"border-style:solid;box-sizing:border-box;border-bottom-color:#e7e7e7;border-left-width:0px;border-bottom-width:1px;border-right-width:0px;border-top-width:0px;padding:10px;background-color:#f8f9fa;border-bottom:1pxsolid#e7e7e7\">Visite</td>\n"
|
||||
" <td style=\"border-style:solid;box-sizing:border-box;border-bottom-color:#e7e7e7;border-left-width:0px;border-bottom-width:1px;border-right-width:0px;border-top-width:0px;padding:10px;border-bottom:1pxsolid#e7e7e7\">\n"
|
||||
" <t t-out=\"object.name\"></t>\n"
|
||||
" </td>\n"
|
||||
" </tr>\n"
|
||||
" <tr style=\"border-style:none;box-sizing:border-box;border-left-width:0px;border-bottom-width:0px;border-right-width:0px;border-top-width:0px\">\n"
|
||||
" <td style=\"border-style:solid;box-sizing:border-box;border-bottom-color:#e7e7e7;border-left-width:0px;border-bottom-width:1px;border-right-width:0px;border-top-width:0px;padding:10px;background-color:#f8f9fa;border-bottom:1pxsolid#e7e7e7\">Début</td>\n"
|
||||
" <td style=\"border-style:solid;box-sizing:border-box;border-bottom-color:#e7e7e7;border-left-width:0px;border-bottom-width:1px;border-right-width:0px;border-top-width:0px;padding:10px;border-bottom:1pxsolid#e7e7e7\">\n"
|
||||
" <t t-set=\"partner\" t-value=\"object.work_order_contacts and object.work_order_contacts[0] or object.partner_id\"></t>\n"
|
||||
" <t t-set=\"partner_tz\" t-value=\"user.tz or object.company_id.partner_id.tz or 'America/Toronto'\"></t>\n"
|
||||
" <span t-field=\"object.planned_date_begin\" t-options=\"{'widget': 'datetime', 'timezone': partner_tz}\"></span>\n"
|
||||
" <div style=\"font-size: 12px; color: #666;\">\n"
|
||||
" (Fuseau horaire: <t t-out=\"partner_tz\"></t>\n"
|
||||
")\n"
|
||||
" </div>\n"
|
||||
" </td>\n"
|
||||
" </tr>\n"
|
||||
" <tr style=\"border-style:none;box-sizing:border-box;border-left-width:0px;border-bottom-width:0px;border-right-width:0px;border-top-width:0px\">\n"
|
||||
" <td style=\"border-style:solid;box-sizing:border-box;border-bottom-color:#e7e7e7;border-left-width:0px;border-bottom-width:1px;border-right-width:0px;border-top-width:0px;padding:10px;background-color:#f8f9fa;border-bottom:1pxsolid#e7e7e7\">Lieu</td>\n"
|
||||
" <td style=\"border-style:solid;box-sizing:border-box;border-bottom-color:#e7e7e7;border-left-width:0px;border-bottom-width:1px;border-right-width:0px;border-top-width:0px;padding:10px;border-bottom:1pxsolid#e7e7e7\">\n"
|
||||
" <t t-out=\"object.partner_id.street\"></t>\n"
|
||||
" <br>\n"
|
||||
" <t t-if=\"object.partner_id.street2\">\n"
|
||||
" <t t-out=\"object.partner_id.street2\"></t>\n"
|
||||
" <br>\n"
|
||||
" </t>\n"
|
||||
" <t t-out=\"object.partner_id.city\"></t>\n"
|
||||
", <t t-out=\"object.partner_id.state_id.code\"></t>\n"
|
||||
" <t t-out=\"object.partner_id.zip\"></t>\n"
|
||||
" </td>\n"
|
||||
" </tr>\n"
|
||||
" <tr style=\"border-style:none;box-sizing:border-box;border-left-width:0px;border-bottom-width:0px;border-right-width:0px;border-top-width:0px\">\n"
|
||||
" <td style=\"border-style:none;box-sizing:border-box;border-left-width:0px;border-bottom-width:0px;border-right-width:0px;border-top-width:0px;padding:10px;background-color:#f8f9fa\">Technicien(s) assigné(s)</td>\n"
|
||||
" <td style=\"border-style:none;box-sizing:border-box;border-left-width:0px;border-bottom-width:0px;border-right-width:0px;border-top-width:0px;padding:10px\">\n"
|
||||
" <t t-foreach=\"object.user_ids\" t-as=\"user\">\n"
|
||||
" <t t-out=\"user.name\"></t>\n"
|
||||
" <t t-if=\"not user_last\">\n"
|
||||
" <br>\n"
|
||||
" </t>\n"
|
||||
" </t>\n"
|
||||
" </td>\n"
|
||||
" </tr>\n"
|
||||
" </tbody></table>\n"
|
||||
" <div style=\"margin: 16px 0px 16px 0px;\">\n"
|
||||
" Veuillez prendre un moment pour confirmer cette visite en cliquant sur une des options suivantes :\n"
|
||||
" </div>\n"
|
||||
" <table style=\"box-sizing:border-box;-webkit-border-vertical-spacing:0px;-webkit-border-horizontal-spacing:0px;border-collapse:collapse;caption-side:bottom;width:100%;border-spacing:0;\" width=\"100%\">\n"
|
||||
" <tbody style=\"border-style:none;box-sizing:border-box;border-left-width:0px;border-bottom-width:0px;border-right-width:0px;border-top-width:0px\"><tr style=\"border-style:none;box-sizing:border-box;border-left-width:0px;border-bottom-width:0px;border-right-width:0px;border-top-width:0px\">\n"
|
||||
" <td style=\"border-style:none;box-sizing:border-box;border-left-width:0px;border-bottom-width:0px;border-right-width:0px;border-top-width:0px;padding:10px;text-align:center\">\n"
|
||||
" <t t-set=\"base_url\" t-value=\"object.get_base_url()\"></t>\n"
|
||||
" <t t-set=\"lang\" t-value=\"object.work_order_contacts and object.work_order_contacts[0].lang or object.partner_id.lang or 'en_US'\"></t>\n"
|
||||
" <a t-att-href=\"base_url + '/my/fsm_confirmation/approve?access_token=' + object.access_token + '&lang=' + lang\" style=\"box-sizing:border-box;background-color:#28a745;padding:8px 16px;text-decoration:none;color:#fff;border-radius:5px;font-size:13px;\">\n"
|
||||
" Confirmer la visite\n"
|
||||
" </a>\n"
|
||||
" </td>\n"
|
||||
" <td style=\"border-style:none;box-sizing:border-box;border-left-width:0px;border-bottom-width:0px;border-right-width:0px;border-top-width:0px;padding:10px;text-align:center\">\n"
|
||||
" <a t-att-href=\"base_url + '/my/fsm_confirmation/change?access_token=' + object.access_token + '&lang=' + lang\" style=\"box-sizing:border-box;background-color:#dc3545;padding:8px 16px;text-decoration:none;color:#fff;border-radius:5px;font-size:13px;\">\n"
|
||||
" Demander des changements\n"
|
||||
" </a>\n"
|
||||
" </td>\n"
|
||||
" </tr>\n"
|
||||
" </tbody></table>\n"
|
||||
" <div style=\"margin-top: 16px;\">\n"
|
||||
" <p style=\"margin:0px 0 16px 0;box-sizing:border-box;\">Cordialement,</p>\n"
|
||||
" <p style=\"margin:0px 0 16px 0;box-sizing:border-box;\">L'équipe de gestion des services de <t t-out=\"object.company_id.name\"></t></p>\n"
|
||||
" </div>\n"
|
||||
" </div>\n"
|
||||
" "
|
||||
|
||||
#. module: fsm_visit_confirmation
|
||||
#: model_terms:ir.ui.view,arch_db:fsm_visit_confirmation.portal_my_task
|
||||
msgid ""
|
||||
"<i class=\"fa fa-check-circle me-2\"/>\n"
|
||||
" <strong>Success!</strong> Thank you for approving this service visit."
|
||||
msgstr ""
|
||||
"<i class=\"fa fa-check-circle me-2\"/>\n"
|
||||
" <strong>Succès!</strong> Merci d'avoir approuvé cette visite de service."
|
||||
|
||||
|
||||
#. module: fsm_visit_confirmation
|
||||
#: model_terms:ir.ui.view,arch_db:fsm_visit_confirmation.portal_my_task
|
||||
msgid ""
|
||||
"<i class=\"fa fa-exclamation-triangle me-2\"/>\n"
|
||||
" Request Changes to Service Visit"
|
||||
msgstr ""
|
||||
"<i class=\"fa fa-exclamation-triangle me-2\"/>\n"
|
||||
" Demander des changements à la visite de service"
|
||||
|
||||
#. module: fsm_visit_confirmation
|
||||
#: model_terms:ir.ui.view,arch_db:fsm_visit_confirmation.portal_my_task
|
||||
msgid ""
|
||||
"<i class=\"fa fa-info-circle me-2\"/>\n"
|
||||
" <strong>Thank you!</strong> Your change request has been submitted. Our team will review it shortly."
|
||||
msgstr ""
|
||||
"<i class=\"fa fa-info-circle me-2\"/>\n"
|
||||
" <strong>Merci!</strong> Votre demande de changement a été soumise. Notre équipe examinerait cette demande rapidement."
|
||||
|
||||
#. module: fsm_visit_confirmation
|
||||
#. odoo-python
|
||||
#: code:addons/fsm_visit_confirmation/controllers/main.py:0
|
||||
#, python-format
|
||||
msgid "Changes requested by customer: %s"
|
||||
msgstr "Changements demandés par client: %s"
|
||||
|
||||
#. module: fsm_visit_confirmation
|
||||
#. odoo-python
|
||||
#: code:addons/fsm_visit_confirmation/controllers/main.py:0
|
||||
#: code:addons/fsm_visit_confirmation/controllers/main.py:0
|
||||
#, python-format
|
||||
msgid "Error approving task: %s"
|
||||
msgstr "Erreur lors de l'approbation de la tâche: %s"
|
||||
|
||||
#. module: fsm_visit_confirmation
|
||||
#. odoo-python
|
||||
#: code:addons/fsm_visit_confirmation/controllers/main.py:0
|
||||
#: code:addons/fsm_visit_confirmation/controllers/main.py:0
|
||||
#, python-format
|
||||
msgid "Error submitting change request: %s"
|
||||
msgstr "Erreur lors de la soumission de la demande de changement: %s"
|
||||
|
||||
#. module: fsm_visit_confirmation
|
||||
#: model:mail.template,name:fsm_visit_confirmation.fsm_visit_confirmation_email_template
|
||||
msgid "FSM Visit Confirmation"
|
||||
msgstr "Confirmation de visite de service"
|
||||
|
||||
#. module: fsm_visit_confirmation
|
||||
#. odoo-python
|
||||
#: code:addons/fsm_visit_confirmation/controllers/main.py:0
|
||||
#, python-format
|
||||
msgid "Invalid action"
|
||||
msgstr "Action invalide"
|
||||
|
||||
#. module: fsm_visit_confirmation
|
||||
#. odoo-python
|
||||
#: code:addons/fsm_visit_confirmation/controllers/main.py:0
|
||||
#, python-format
|
||||
msgid "No feedback provided. Please try again!"
|
||||
msgstr "Aucun commentaire fourni. Veuillez réessayer!"
|
||||
|
||||
#. module: fsm_visit_confirmation
|
||||
#: model_terms:ir.ui.view,arch_db:fsm_visit_confirmation.portal_my_task
|
||||
msgid "Please explain what changes you need..."
|
||||
msgstr "Veuillez expliquer les modifications nécessaires..."
|
||||
|
||||
#. module: fsm_visit_confirmation
|
||||
#: model_terms:ir.ui.view,arch_db:fsm_visit_confirmation.portal_my_task
|
||||
msgid ""
|
||||
"Please provide details about the changes you would like to make to this "
|
||||
"service visit:"
|
||||
msgstr ""
|
||||
"Veuillez fournir des informations sur les modifications que vous souhaitez "
|
||||
"apporter à cette visite de service :"
|
||||
|
||||
#. module: fsm_visit_confirmation
|
||||
#: model:mail.template,description:fsm_visit_confirmation.fsm_visit_confirmation_email_template
|
||||
msgid "Send a visit confirmation email to the customer"
|
||||
msgstr "Envoyer un email de confirmation de visite au client"
|
||||
|
||||
#. module: fsm_visit_confirmation
|
||||
#: model:mail.template,subject:fsm_visit_confirmation.fsm_visit_confirmation_email_template
|
||||
msgid "Service Visit Confirmation: {{ object.name }}"
|
||||
msgstr "Confirmation de visite de service: {{ object.name }}"
|
||||
|
||||
#. module: fsm_visit_confirmation
|
||||
#: model_terms:ir.ui.view,arch_db:fsm_visit_confirmation.portal_my_task
|
||||
msgid "Submit Request"
|
||||
msgstr "Soumettre la demande"
|
||||
|
||||
#. module: fsm_visit_confirmation
|
||||
#. odoo-python
|
||||
#: code:addons/fsm_visit_confirmation/controllers/main.py:0
|
||||
#: code:addons/fsm_visit_confirmation/controllers/main.py:0
|
||||
#: code:addons/fsm_visit_confirmation/controllers/main.py:0
|
||||
#, python-format
|
||||
msgid "Task not found or invalid token"
|
||||
msgstr "Tâche introuvable ou jeton invalide"
|
||||
|
||||
#. module: fsm_visit_confirmation
|
||||
#. odoo-python
|
||||
#: code:addons/fsm_visit_confirmation/controllers/main.py:0
|
||||
#, python-format
|
||||
msgid "Visit approved by customer"
|
||||
msgstr "Visite approuvée par le client"
|
||||
|
||||
#. module: fsm_visit_confirmation
|
||||
#: model_terms:ir.ui.view,arch_db:fsm_visit_confirmation.portal_my_task
|
||||
msgid "Your Comments:"
|
||||
msgstr "Vos commentaires :"
|
||||
211
fsm_visit_confirmation/i18n/fsm_visit_confirmation.pot
Normal file
211
fsm_visit_confirmation/i18n/fsm_visit_confirmation.pot
Normal file
|
|
@ -0,0 +1,211 @@
|
|||
# Translation of Odoo Server.
|
||||
# This file contains the translation of the following modules:
|
||||
# * fsm_visit_confirmation
|
||||
#
|
||||
msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: Odoo Server 17.0+e\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2025-02-27 00:31+0000\n"
|
||||
"PO-Revision-Date: 2025-02-27 00:31+0000\n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: \n"
|
||||
"MIME-Version: 1.0\n"
|
||||
"Content-Type: text/plain; charset=UTF-8\n"
|
||||
"Content-Transfer-Encoding: \n"
|
||||
"Plural-Forms: \n"
|
||||
|
||||
#. module: fsm_visit_confirmation
|
||||
#: model:mail.template,body_html:fsm_visit_confirmation.fsm_visit_confirmation_email_template
|
||||
msgid ""
|
||||
"<div>\n"
|
||||
" <div style=\"margin-bottom: 16px;\">\n"
|
||||
" Dear <t t-out=\"object.work_order_contacts and object.work_order_contacts[0].name or object.partner_id.name\"></t>\n"
|
||||
",\n"
|
||||
" </div>\n"
|
||||
" <div style=\"margin-bottom: 16px;\">\n"
|
||||
" We would like to confirm the details of your upcoming service visit:\n"
|
||||
" </div>\n"
|
||||
" <table style=\"border-style:solid;box-sizing:border-box;border-left-color:#e7e7e7;border-bottom-color:#e7e7e7;border-right-color:#e7e7e7;border-top-color:#e7e7e7;border-left-width:1px;border-bottom-width:1px;border-right-width:1px;border-top-width:1px;-webkit-border-vertical-spacing:0px;-webkit-border-horizontal-spacing:0px;border-collapse:collapse;caption-side:bottom;width:100%;border-spacing:0;border:1pxsolid#e7e7e7\" width=\"100%\">\n"
|
||||
" <tbody style=\"border-style:none;box-sizing:border-box;border-left-width:0px;border-bottom-width:0px;border-right-width:0px;border-top-width:0px\"><tr style=\"border-style:none;box-sizing:border-box;border-left-width:0px;border-bottom-width:0px;border-right-width:0px;border-top-width:0px\">\n"
|
||||
" <td style=\"border-style:solid;box-sizing:border-box;border-bottom-color:#e7e7e7;border-left-width:0px;border-bottom-width:1px;border-right-width:0px;border-top-width:0px;padding:10px;background-color:#f8f9fa;border-bottom:1pxsolid#e7e7e7\">Task Summary</td>\n"
|
||||
" <td style=\"border-style:solid;box-sizing:border-box;border-bottom-color:#e7e7e7;border-left-width:0px;border-bottom-width:1px;border-right-width:0px;border-top-width:0px;padding:10px;border-bottom:1pxsolid#e7e7e7\">\n"
|
||||
" <t t-out=\"object.name\"></t>\n"
|
||||
" </td>\n"
|
||||
" </tr>\n"
|
||||
" <tr style=\"border-style:none;box-sizing:border-box;border-left-width:0px;border-bottom-width:0px;border-right-width:0px;border-top-width:0px\">\n"
|
||||
" <td style=\"border-style:solid;box-sizing:border-box;border-bottom-color:#e7e7e7;border-left-width:0px;border-bottom-width:1px;border-right-width:0px;border-top-width:0px;padding:10px;background-color:#f8f9fa;border-bottom:1pxsolid#e7e7e7\">Technician Arrival</td>\n"
|
||||
" <td style=\"border-style:solid;box-sizing:border-box;border-bottom-color:#e7e7e7;border-left-width:0px;border-bottom-width:1px;border-right-width:0px;border-top-width:0px;padding:10px;border-bottom:1pxsolid#e7e7e7\">\n"
|
||||
" <t t-set=\"partner\" t-value=\"object.work_order_contacts and object.work_order_contacts[0] or object.partner_id\"></t>\n"
|
||||
" <t t-set=\"partner_tz\" t-value=\"user.tz or object.company_id.partner_id.tz or 'America/Toronto'\"></t>\n"
|
||||
" <span t-field=\"object.planned_date_begin\" t-options=\"{'widget': 'datetime', 'timezone': partner_tz}\"></span>\n"
|
||||
" <div style=\"font-size: 12px; color: #666;\">\n"
|
||||
" (Timezone: <t t-out=\"partner_tz\"></t>\n"
|
||||
")\n"
|
||||
" </div>\n"
|
||||
" </td>\n"
|
||||
" </tr>\n"
|
||||
" <tr style=\"border-style:none;box-sizing:border-box;border-left-width:0px;border-bottom-width:0px;border-right-width:0px;border-top-width:0px\">\n"
|
||||
" <td style=\"border-style:solid;box-sizing:border-box;border-bottom-color:#e7e7e7;border-left-width:0px;border-bottom-width:1px;border-right-width:0px;border-top-width:0px;padding:10px;background-color:#f8f9fa;border-bottom:1pxsolid#e7e7e7\">Location</td>\n"
|
||||
" <td style=\"border-style:solid;box-sizing:border-box;border-bottom-color:#e7e7e7;border-left-width:0px;border-bottom-width:1px;border-right-width:0px;border-top-width:0px;padding:10px;border-bottom:1pxsolid#e7e7e7\">\n"
|
||||
" <t t-out=\"object.partner_id.street\"></t>\n"
|
||||
" <br>\n"
|
||||
" <t t-if=\"object.partner_id.street2\">\n"
|
||||
" <t t-out=\"object.partner_id.street2\"></t>\n"
|
||||
" <br>\n"
|
||||
" </t>\n"
|
||||
" <t t-out=\"object.partner_id.city\"></t>\n"
|
||||
", <t t-out=\"object.partner_id.state_id.code\"></t>\n"
|
||||
" <t t-out=\"object.partner_id.zip\"></t>\n"
|
||||
" </td>\n"
|
||||
" </tr>\n"
|
||||
" <tr style=\"border-style:none;box-sizing:border-box;border-left-width:0px;border-bottom-width:0px;border-right-width:0px;border-top-width:0px\">\n"
|
||||
" <td style=\"border-style:none;box-sizing:border-box;border-left-width:0px;border-bottom-width:0px;border-right-width:0px;border-top-width:0px;padding:10px;background-color:#f8f9fa\">Assigned Technician(s)</td>\n"
|
||||
" <td style=\"border-style:none;box-sizing:border-box;border-left-width:0px;border-bottom-width:0px;border-right-width:0px;border-top-width:0px;padding:10px\">\n"
|
||||
" <t t-foreach=\"object.user_ids\" t-as=\"user\">\n"
|
||||
" <t t-out=\"user.name\"></t>\n"
|
||||
" <t t-if=\"not user_last\">\n"
|
||||
" <br>\n"
|
||||
" </t>\n"
|
||||
" </t>\n"
|
||||
" </td>\n"
|
||||
" </tr>\n"
|
||||
" </tbody></table>\n"
|
||||
" <div style=\"margin: 16px 0px 16px 0px;\">\n"
|
||||
" Please take a moment to confirm this visit by clicking one of the following options:\n"
|
||||
" </div>\n"
|
||||
" <table style=\"box-sizing:border-box;-webkit-border-vertical-spacing:0px;-webkit-border-horizontal-spacing:0px;border-collapse:collapse;caption-side:bottom;width:100%;border-spacing:0;\" width=\"100%\">\n"
|
||||
" <tbody style=\"border-style:none;box-sizing:border-box;border-left-width:0px;border-bottom-width:0px;border-right-width:0px;border-top-width:0px\"><tr style=\"border-style:none;box-sizing:border-box;border-left-width:0px;border-bottom-width:0px;border-right-width:0px;border-top-width:0px\">\n"
|
||||
" <td style=\"border-style:none;box-sizing:border-box;border-left-width:0px;border-bottom-width:0px;border-right-width:0px;border-top-width:0px;padding:10px;text-align:center\">\n"
|
||||
" <t t-set=\"base_url\" t-value=\"object.get_base_url()\"></t>\n"
|
||||
" <t t-set=\"lang\" t-value=\"object.work_order_contacts and object.work_order_contacts[0].lang or object.partner_id.lang or 'en_US'\"></t>\n"
|
||||
" <a t-att-href=\"base_url + '/my/fsm_confirmation/approve?access_token=' + object.access_token + '&lang=' + lang\" style=\"box-sizing:border-box;background-color:#28a745;padding:8px 16px;text-decoration:none;color:#fff;border-radius:5px;font-size:13px;\">\n"
|
||||
" Approve Visit\n"
|
||||
" </a>\n"
|
||||
" </td>\n"
|
||||
" <td style=\"border-style:none;box-sizing:border-box;border-left-width:0px;border-bottom-width:0px;border-right-width:0px;border-top-width:0px;padding:10px;text-align:center\">\n"
|
||||
" <a t-att-href=\"base_url + '/my/fsm_confirmation/change?access_token=' + object.access_token + '&lang=' + lang\" style=\"box-sizing:border-box;background-color:#dc3545;padding:8px 16px;text-decoration:none;color:#fff;border-radius:5px;font-size:13px;\">\n"
|
||||
" Request Changes\n"
|
||||
" </a>\n"
|
||||
" </td>\n"
|
||||
" </tr>\n"
|
||||
" </tbody></table>\n"
|
||||
" <div style=\"margin-top: 16px;\">\n"
|
||||
" <p style=\"margin:0px 0 16px 0;box-sizing:border-box;\">Best regards,</p>\n"
|
||||
" <p style=\"margin:0px 0 16px 0;box-sizing:border-box;\">The <t t-out=\"object.company_id.name\"></t>\n"
|
||||
" service management team</p>\n"
|
||||
" </div>\n"
|
||||
" </div>\n"
|
||||
" "
|
||||
msgstr ""
|
||||
|
||||
#. module: fsm_visit_confirmation
|
||||
#: model_terms:ir.ui.view,arch_db:fsm_visit_confirmation.portal_my_task
|
||||
msgid ""
|
||||
"<i class=\"fa fa-check-circle me-2\"/>\n"
|
||||
" <strong>Success!</strong> Thank you for approving this service visit."
|
||||
msgstr ""
|
||||
|
||||
#. module: fsm_visit_confirmation
|
||||
#: model_terms:ir.ui.view,arch_db:fsm_visit_confirmation.portal_my_task
|
||||
msgid ""
|
||||
"<i class=\"fa fa-exclamation-triangle me-2\"/>\n"
|
||||
" Request Changes to Service Visit"
|
||||
msgstr ""
|
||||
|
||||
#. module: fsm_visit_confirmation
|
||||
#: model_terms:ir.ui.view,arch_db:fsm_visit_confirmation.portal_my_task
|
||||
msgid ""
|
||||
"<i class=\"fa fa-info-circle me-2\"/>\n"
|
||||
" <strong>Thank you!</strong> Your change request has been submitted. Our team will review it shortly."
|
||||
msgstr ""
|
||||
|
||||
#. module: fsm_visit_confirmation
|
||||
#. odoo-python
|
||||
#: code:addons/fsm_visit_confirmation/controllers/main.py:0
|
||||
#, python-format
|
||||
msgid "Changes requested by customer: %s"
|
||||
msgstr ""
|
||||
|
||||
#. module: fsm_visit_confirmation
|
||||
#. odoo-python
|
||||
#: code:addons/fsm_visit_confirmation/controllers/main.py:0
|
||||
#: code:addons/fsm_visit_confirmation/controllers/main.py:0
|
||||
#, python-format
|
||||
msgid "Error approving task: %s"
|
||||
msgstr ""
|
||||
|
||||
#. module: fsm_visit_confirmation
|
||||
#. odoo-python
|
||||
#: code:addons/fsm_visit_confirmation/controllers/main.py:0
|
||||
#: code:addons/fsm_visit_confirmation/controllers/main.py:0
|
||||
#, python-format
|
||||
msgid "Error submitting change request: %s"
|
||||
msgstr ""
|
||||
|
||||
#. module: fsm_visit_confirmation
|
||||
#: model:mail.template,name:fsm_visit_confirmation.fsm_visit_confirmation_email_template
|
||||
msgid "FSM Visit Confirmation"
|
||||
msgstr ""
|
||||
|
||||
#. module: fsm_visit_confirmation
|
||||
#. odoo-python
|
||||
#: code:addons/fsm_visit_confirmation/controllers/main.py:0
|
||||
#, python-format
|
||||
msgid "Invalid action"
|
||||
msgstr ""
|
||||
|
||||
#. module: fsm_visit_confirmation
|
||||
#. odoo-python
|
||||
#: code:addons/fsm_visit_confirmation/controllers/main.py:0
|
||||
#, python-format
|
||||
msgid "No feedback provided. Please try again!"
|
||||
msgstr ""
|
||||
|
||||
#. module: fsm_visit_confirmation
|
||||
#: model_terms:ir.ui.view,arch_db:fsm_visit_confirmation.portal_my_task
|
||||
msgid "Please explain what changes you need..."
|
||||
msgstr ""
|
||||
|
||||
#. module: fsm_visit_confirmation
|
||||
#: model_terms:ir.ui.view,arch_db:fsm_visit_confirmation.portal_my_task
|
||||
msgid ""
|
||||
"Please provide details about the changes you would like to make to this "
|
||||
"service visit:"
|
||||
msgstr ""
|
||||
|
||||
#. module: fsm_visit_confirmation
|
||||
#: model:mail.template,description:fsm_visit_confirmation.fsm_visit_confirmation_email_template
|
||||
msgid "Send a visit confirmation email to the customer"
|
||||
msgstr ""
|
||||
|
||||
#. module: fsm_visit_confirmation
|
||||
#: model:mail.template,subject:fsm_visit_confirmation.fsm_visit_confirmation_email_template
|
||||
msgid "Service Visit Confirmation: {{ object.name }}"
|
||||
msgstr ""
|
||||
|
||||
#. module: fsm_visit_confirmation
|
||||
#: model_terms:ir.ui.view,arch_db:fsm_visit_confirmation.portal_my_task
|
||||
msgid "Submit Request"
|
||||
msgstr ""
|
||||
|
||||
#. module: fsm_visit_confirmation
|
||||
#. odoo-python
|
||||
#: code:addons/fsm_visit_confirmation/controllers/main.py:0
|
||||
#: code:addons/fsm_visit_confirmation/controllers/main.py:0
|
||||
#: code:addons/fsm_visit_confirmation/controllers/main.py:0
|
||||
#, python-format
|
||||
msgid "Task not found or invalid token"
|
||||
msgstr ""
|
||||
|
||||
#. module: fsm_visit_confirmation
|
||||
#. odoo-python
|
||||
#: code:addons/fsm_visit_confirmation/controllers/main.py:0
|
||||
#, python-format
|
||||
msgid "Visit approved by customer"
|
||||
msgstr ""
|
||||
|
||||
#. module: fsm_visit_confirmation
|
||||
#: model_terms:ir.ui.view,arch_db:fsm_visit_confirmation.portal_my_task
|
||||
msgid "Your Comments:"
|
||||
msgstr ""
|
||||
|
|
@ -1 +1,2 @@
|
|||
from . import project_task_type
|
||||
from . import project_task
|
||||
|
|
|
|||
|
|
@ -1,9 +1,31 @@
|
|||
from odoo import models, fields, api
|
||||
from odoo import models
|
||||
|
||||
|
||||
class Task(models.Model):
|
||||
class ProjectTask(models.Model):
|
||||
_inherit = "project.task"
|
||||
|
||||
def action_approve_booking(self):
|
||||
self.ensure_one()
|
||||
self.state = "03_approved"
|
||||
def write(self, vals):
|
||||
# Store old stage for comparison
|
||||
old_stages = {task.id: task.stage_id for task in self}
|
||||
|
||||
# Execute original write method
|
||||
result = super().write(vals)
|
||||
|
||||
# If stage changed and new stage has an approval template, send the email
|
||||
if "stage_id" in vals:
|
||||
# Only send email for top-level tasks
|
||||
for task in self.filtered(
|
||||
lambda task: task.stage_id != old_stages[task.id]
|
||||
and task.stage_id.approval_template_id
|
||||
and not task.parent_id
|
||||
):
|
||||
task._portal_ensure_token()
|
||||
task.stage_id.approval_template_id.send_mail(
|
||||
task.id,
|
||||
force_send=True,
|
||||
email_values={
|
||||
"email_to": task.partner_id.email if task.partner_id else False
|
||||
},
|
||||
)
|
||||
|
||||
return result
|
||||
|
|
|
|||
11
fsm_visit_confirmation/models/project_task_type.py
Normal file
11
fsm_visit_confirmation/models/project_task_type.py
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
from odoo import fields, models
|
||||
|
||||
|
||||
class ProjectTaskType(models.Model):
|
||||
_inherit = 'project.task.type'
|
||||
|
||||
approval_template_id = fields.Many2one(
|
||||
'mail.template',
|
||||
string='Approval Template',
|
||||
help='Template to use for approval emails when a task reaches this stage.'
|
||||
)
|
||||
1
fsm_visit_confirmation/tests/__init__.py
Normal file
1
fsm_visit_confirmation/tests/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
from . import test_fsm_visit_confirmation
|
||||
206
fsm_visit_confirmation/tests/test_fsm_visit_confirmation.py
Normal file
206
fsm_visit_confirmation/tests/test_fsm_visit_confirmation.py
Normal file
|
|
@ -0,0 +1,206 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
from odoo import http
|
||||
from odoo.addons.mail.tests.common import MailCommon
|
||||
from odoo.tests import HttpCase, tagged
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@tagged("post_install", "-at_install")
|
||||
class TestFSMVisitConfirmation(HttpCase, MailCommon):
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
|
||||
# Configure test company
|
||||
self.env.company.write(
|
||||
{
|
||||
"email": "company@test.example.com",
|
||||
"name": "Test Company",
|
||||
}
|
||||
)
|
||||
|
||||
# Create test users and partners
|
||||
self.fsm_user = self.env["res.users"].create(
|
||||
{
|
||||
"name": "FSM User",
|
||||
"login": "fsm_user",
|
||||
"email": "fsm@example.com",
|
||||
"groups_id": [
|
||||
(
|
||||
6,
|
||||
0,
|
||||
[
|
||||
self.env.ref("industry_fsm.group_fsm_user").id,
|
||||
],
|
||||
)
|
||||
],
|
||||
}
|
||||
)
|
||||
# Set email on user's partner
|
||||
self.fsm_user.partner_id.email = "fsm@example.com"
|
||||
self.fsm_user.partner_id.lang = "en_US" # Set language explicitly
|
||||
|
||||
self.customer = self.env["res.partner"].create(
|
||||
{
|
||||
"name": "Customer",
|
||||
"email": "customer@example.com",
|
||||
"lang": "en_US", # Set language explicitly
|
||||
}
|
||||
)
|
||||
|
||||
# Create a project
|
||||
self.project = self.env["project.project"].create(
|
||||
{
|
||||
"name": "Test FSM Project",
|
||||
"is_fsm": True,
|
||||
"company_id": self.env.user.company_id.id,
|
||||
}
|
||||
)
|
||||
|
||||
# Create stages for the project
|
||||
self.stage_new = self.env["project.task.type"].create(
|
||||
{
|
||||
"name": "New",
|
||||
"sequence": 0,
|
||||
"project_ids": [(4, self.project.id)],
|
||||
}
|
||||
)
|
||||
|
||||
self.stage_needs_confirmation = self.env["project.task.type"].create(
|
||||
{
|
||||
"name": "Need Confirmation",
|
||||
"sequence": 1,
|
||||
"project_ids": [(4, self.project.id)],
|
||||
}
|
||||
)
|
||||
|
||||
self.stage_approved = self.env["project.task.type"].create(
|
||||
{
|
||||
"name": "Approved",
|
||||
"sequence": 2,
|
||||
"project_ids": [(4, self.project.id)],
|
||||
}
|
||||
)
|
||||
|
||||
self.stage_changes_requested = self.env["project.task.type"].create(
|
||||
{
|
||||
"name": "Changes Requested",
|
||||
"sequence": 3,
|
||||
"project_ids": [(4, self.project.id)],
|
||||
}
|
||||
)
|
||||
|
||||
# Create a task
|
||||
self.task = self.env["project.task"].create(
|
||||
{
|
||||
"name": "Test Task",
|
||||
"project_id": self.project.id,
|
||||
"partner_id": self.customer.id,
|
||||
"work_order_contacts": [(4, self.customer.id)],
|
||||
"user_ids": [(4, self.fsm_user.id)],
|
||||
"planned_date_begin": datetime.now() + timedelta(days=1),
|
||||
"stage_id": self.stage_new.id,
|
||||
"state": "01_in_progress", # Using valid state value
|
||||
}
|
||||
)
|
||||
self.assertEqual(
|
||||
self.task.stage_id, self.stage_new, "Task should start in New stage"
|
||||
)
|
||||
|
||||
# Ensure the task has an access token
|
||||
if not self.task.access_token:
|
||||
self.task._portal_ensure_token()
|
||||
self.token = self.task.access_token
|
||||
self.assertTrue(self.token, "Task should have an access token")
|
||||
|
||||
def test_01_task_approve_flow(self):
|
||||
"""Test the complete task approval flow"""
|
||||
# Test the FSM confirmation approve endpoint
|
||||
self.authenticate(None, None)
|
||||
url = f"/my/fsm_confirmation/approve?access_token={self.token}"
|
||||
response = self.url_open(url)
|
||||
self.assertEqual(
|
||||
response.status_code, 200, "FSM confirmation approve should succeed"
|
||||
)
|
||||
|
||||
# Now check that the task state was updated
|
||||
self.task.invalidate_recordset()
|
||||
self.assertEqual(
|
||||
self.task.state, "03_approved", "Task state should be updated to approved"
|
||||
)
|
||||
|
||||
# Check that a message was posted
|
||||
messages = self.env["mail.message"].search(
|
||||
[
|
||||
("model", "=", "project.task"),
|
||||
("res_id", "=", self.task.id),
|
||||
("body", "ilike", "Visit approved by customer"),
|
||||
]
|
||||
)
|
||||
self.assertTrue(messages, "A message should be posted on the task")
|
||||
|
||||
def test_03_stage_approved_sends_email(self):
|
||||
"""Test that moving a task to the approved stage sends an email"""
|
||||
# Set the approval template on the approved stage
|
||||
template = self.env.ref(
|
||||
"fsm_visit_confirmation.fsm_visit_confirmation_email_template"
|
||||
)
|
||||
self.stage_approved.approval_template_id = template
|
||||
|
||||
with self.mock_mail_gateway():
|
||||
self.task.write({"stage_id": self.stage_approved.id})
|
||||
|
||||
self.assertEqual(
|
||||
len(self._new_mails),
|
||||
1,
|
||||
"Moving task to approved stage should send an email",
|
||||
)
|
||||
self.assertEqual(self._new_mails[0].email_to, self.customer.email)
|
||||
|
||||
def test_02_task_request_changes_flow(self):
|
||||
"""Test the complete task request changes flow"""
|
||||
# Test the FSM confirmation change endpoint
|
||||
self.authenticate(None, None)
|
||||
url = f"/my/fsm_confirmation/change?access_token={self.token}"
|
||||
response = self.url_open(url)
|
||||
self.assertEqual(
|
||||
response.status_code, 200, "FSM confirmation change should succeed"
|
||||
)
|
||||
|
||||
# Now submit the change request form
|
||||
url = f"/my/fsm_confirmation/submit_change"
|
||||
response = self.url_open(
|
||||
url,
|
||||
data={
|
||||
"access_token": self.token,
|
||||
"feedback": "Need some changes",
|
||||
"csrf_token": http.Request.csrf_token(self),
|
||||
},
|
||||
)
|
||||
self.assertEqual(
|
||||
response.status_code, 200, "Change request submission should succeed"
|
||||
)
|
||||
|
||||
# Check that the task state was updated
|
||||
self.task.invalidate_recordset()
|
||||
self.assertEqual(
|
||||
self.task.state,
|
||||
"02_changes_requested",
|
||||
"Task state should be updated to changes requested",
|
||||
)
|
||||
|
||||
# Check that a message was posted with the feedback
|
||||
messages = self.env["mail.message"].search(
|
||||
[
|
||||
("model", "=", "project.task"),
|
||||
("res_id", "=", self.task.id),
|
||||
("body", "ilike", "Need some changes"),
|
||||
]
|
||||
)
|
||||
self.assertTrue(
|
||||
messages, "A message with feedback should be posted on the task"
|
||||
)
|
||||
|
|
@ -1,10 +1,47 @@
|
|||
<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<odoo>
|
||||
<template id="portal_my_task" inherit_id="project.portal_my_task">
|
||||
<xpath expr="//div[@id='task_content']" position="before">
|
||||
<div t-if="visit_confirmation_accepted" class="alert alert-success" role="alert">
|
||||
<strong>Success!</strong> We've received your confirmation. Thank you!
|
||||
<!-- Add success message when visit is approved -->
|
||||
<xpath expr="//div[@id='task_content']" position="inside">
|
||||
<div t-if="request.params.get('visit_confirmation_status') == 'approved'" class="alert alert-success" role="alert">
|
||||
<i class="fa fa-check-circle me-2"></i>
|
||||
<strong>Success!</strong> Thank you for approving this service visit.
|
||||
</div>
|
||||
<div t-if="request.params.get('visit_confirmation_status') == 'change_submitted'" class="alert alert-info" role="alert">
|
||||
<i class="fa fa-info-circle me-2"></i>
|
||||
<strong>Thank you!</strong> Your change request has been submitted. Our team will review it shortly.
|
||||
</div>
|
||||
</xpath>
|
||||
|
||||
<!-- Add change request form when needed -->
|
||||
<xpath expr="//div[@id='task_chat']" position="before">
|
||||
<div t-if="request.params.get('visit_confirmation_status') == 'change'" class="mb-4 mt-4">
|
||||
<div class="card">
|
||||
<div class="card-header bg-warning text-white">
|
||||
<h5 class="mb-0">
|
||||
<i class="fa fa-exclamation-triangle me-2"></i>
|
||||
Request Changes to Service Visit
|
||||
</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<p>Please provide details about the changes you would like to make to this service visit:</p>
|
||||
<form action="/my/fsm_confirmation/submit_change" method="post">
|
||||
<input type="hidden" name="csrf_token" t-att-value="request.csrf_token()"/>
|
||||
<input type="hidden" name="access_token" t-att-value="request.params.get('access_token')"/>
|
||||
<input type="hidden" name="lang" t-att-value="request.context.get('lang') or request.params.get('lang')"/>
|
||||
|
||||
<div class="form-group mb-3">
|
||||
<label for="feedback" class="form-label">Your Comments:</label>
|
||||
<textarea class="form-control" name="feedback" id="feedback" rows="4" required="required" placeholder="Please explain what changes you need..."></textarea>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn btn-warning">
|
||||
Submit Request
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</xpath>
|
||||
</template>
|
||||
</odoo>
|
||||
</odoo>
|
||||
|
|
|
|||
13
fsm_visit_confirmation/views/project_task_type_views.xml
Normal file
13
fsm_visit_confirmation/views/project_task_type_views.xml
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<record id="view_project_task_type_form_inherit_fsm_visit_confirmation" model="ir.ui.view">
|
||||
<field name="name">project.task.type.form.inherit.fsm.visit.confirmation</field>
|
||||
<field name="model">project.task.type</field>
|
||||
<field name="inherit_id" ref="project.task_type_edit"/>
|
||||
<field name="arch" type="xml">
|
||||
<field name="mail_template_id" position="after">
|
||||
<field name="approval_template_id"/>
|
||||
</field>
|
||||
</field>
|
||||
</record>
|
||||
</odoo>
|
||||
1
interactive_discuss_ai/__init__.py
Normal file
1
interactive_discuss_ai/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
from . import models
|
||||
46
interactive_discuss_ai/__manifest__.py
Normal file
46
interactive_discuss_ai/__manifest__.py
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
{
|
||||
'name': 'Interactive AI Voice Assistant',
|
||||
'version': '17.0.1.0.0',
|
||||
'category': 'Productivity/Communication',
|
||||
'summary': 'Natural Language Voice Interaction for Odoo using Open WebUI',
|
||||
'sequence': 1,
|
||||
'author': 'Benoît Vézina',
|
||||
'website': 'https://bemade.org',
|
||||
'maintainer': 'it@bemade.org',
|
||||
'license': 'LGPL-3',
|
||||
'description': """
|
||||
Interactive AI Voice Assistant for Odoo
|
||||
=====================================
|
||||
|
||||
This module enables natural language voice interaction with Odoo directly in the Discuss interface:
|
||||
- Voice input processing using Open WebUI models
|
||||
- Interactive AI chat assistant
|
||||
- Smart context-aware responses
|
||||
- Draft mode for new records creation
|
||||
""",
|
||||
'depends': [
|
||||
'base',
|
||||
'web',
|
||||
'mail',
|
||||
],
|
||||
'data': [
|
||||
'security/ir.model.access.csv',
|
||||
'views/ai_assistant_views.xml',
|
||||
'views/ai_config_views.xml',
|
||||
],
|
||||
'assets': {
|
||||
'web.assets_backend': [
|
||||
'interactive_discuss_ai/static/src/js/ai_assistant_chat.js',
|
||||
'interactive_discuss_ai/static/src/xml/ai_assistant_chat.xml',
|
||||
],
|
||||
},
|
||||
'demo': [],
|
||||
'installable': True,
|
||||
'application': True,
|
||||
'auto_install': False,
|
||||
'external_dependencies': {
|
||||
'python': [
|
||||
'requests',
|
||||
],
|
||||
},
|
||||
}
|
||||
2
interactive_discuss_ai/models/__init__.py
Normal file
2
interactive_discuss_ai/models/__init__.py
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
from . import ai_assistant
|
||||
from . import ai_config
|
||||
101
interactive_discuss_ai/models/ai_assistant.py
Normal file
101
interactive_discuss_ai/models/ai_assistant.py
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
from odoo import models, fields, api, _
|
||||
from odoo.exceptions import UserError
|
||||
|
||||
class AIAssistantChannel(models.Model):
|
||||
_name = 'ai.assistant.channel'
|
||||
_description = 'AI Assistant Channel'
|
||||
_inherit = ['mail.thread']
|
||||
|
||||
name = fields.Char(default="Assistant AI", readonly=True, tracking=True)
|
||||
company_id = fields.Many2one(
|
||||
'res.company',
|
||||
string='Company',
|
||||
required=True,
|
||||
default=lambda self: self.env.company,
|
||||
tracking=True
|
||||
)
|
||||
active = fields.Boolean(default=True, tracking=True)
|
||||
user_id = fields.Many2one(
|
||||
'res.users',
|
||||
string='User',
|
||||
required=True,
|
||||
default=lambda self: self.env.user,
|
||||
tracking=True
|
||||
)
|
||||
message_ids = fields.One2many(
|
||||
'mail.message',
|
||||
'res_id',
|
||||
string='Messages',
|
||||
domain=lambda self: [('model', '=', self._name)],
|
||||
tracking=True
|
||||
)
|
||||
|
||||
@api.model_create_multi
|
||||
def create(self, vals_list):
|
||||
records = super().create(vals_list)
|
||||
for record in records:
|
||||
# Create a dedicated discussion channel for the assistant
|
||||
channel = self.env['mail.channel'].create({
|
||||
'name': _('Assistant AI - %s', record.company_id.name),
|
||||
'channel_type': 'chat',
|
||||
'channel_partner_ids': [(4, record.user_id.partner_id.id)],
|
||||
})
|
||||
return records
|
||||
|
||||
def _get_conversation_history(self, limit=10):
|
||||
"""Get recent conversation history"""
|
||||
self.ensure_one()
|
||||
messages = self.env['mail.message'].search([
|
||||
('model', '=', self._name),
|
||||
('res_id', '=', self.id)
|
||||
], limit=limit, order='id desc')
|
||||
|
||||
history = []
|
||||
for msg in reversed(messages):
|
||||
role = "assistant" if msg.author_id == self.user_id.partner_id else "user"
|
||||
history.append({
|
||||
"role": role,
|
||||
"content": msg.body
|
||||
})
|
||||
return history
|
||||
|
||||
def process_voice_message(self, voice_input):
|
||||
"""Process voice input and respond"""
|
||||
self.ensure_one()
|
||||
if not voice_input:
|
||||
raise UserError(_('No voice input provided'))
|
||||
|
||||
# Get conversation history
|
||||
conversation_history = self._get_conversation_history()
|
||||
|
||||
# Use AI service to generate response
|
||||
ai_service = self.env['ai.service'].with_company(self.company_id)
|
||||
response = ai_service.generate_response(voice_input, conversation_history)
|
||||
|
||||
# Post response as message
|
||||
self.message_post(
|
||||
body=response,
|
||||
message_type='comment',
|
||||
subtype_xmlid='mail.mt_comment'
|
||||
)
|
||||
return True
|
||||
|
||||
@api.model
|
||||
def get_assistant_for_user(self, user_id=None):
|
||||
"""Get or create an assistant for the user in their company"""
|
||||
if not user_id:
|
||||
user_id = self.env.user.id
|
||||
|
||||
assistant = self.search([
|
||||
('user_id', '=', user_id),
|
||||
('company_id', '=', self.env.company.id),
|
||||
('active', '=', True)
|
||||
], limit=1)
|
||||
|
||||
if not assistant:
|
||||
assistant = self.create({
|
||||
'user_id': user_id,
|
||||
'company_id': self.env.company.id
|
||||
})
|
||||
|
||||
return assistant
|
||||
61
interactive_discuss_ai/models/ai_config.py
Normal file
61
interactive_discuss_ai/models/ai_config.py
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
from odoo import models, fields, api
|
||||
from odoo.exceptions import UserError
|
||||
|
||||
class AIConfig(models.Model):
|
||||
_name = 'ai.config'
|
||||
_description = 'AI Configuration'
|
||||
_inherit = ['mail.thread']
|
||||
|
||||
name = fields.Char(string='Name', required=True, default='Default Config', tracking=True)
|
||||
company_id = fields.Many2one(
|
||||
'res.company',
|
||||
string='Company',
|
||||
required=True,
|
||||
default=lambda self: self.env.company,
|
||||
tracking=True
|
||||
)
|
||||
openwebui_url = fields.Char(
|
||||
string='Open WebUI URL',
|
||||
required=True,
|
||||
default='http://localhost:8080',
|
||||
tracking=True
|
||||
)
|
||||
model_name = fields.Char(
|
||||
string='Model Name',
|
||||
required=True,
|
||||
help='Nom du modèle à utiliser dans Open WebUI',
|
||||
tracking=True
|
||||
)
|
||||
system_prompt = fields.Text(
|
||||
string='System Prompt',
|
||||
default="""Tu es un assistant Odoo expert qui aide les utilisateurs à effectuer des actions
|
||||
dans le système. Analyse leurs demandes et propose des actions concrètes.""",
|
||||
tracking=True
|
||||
)
|
||||
temperature = fields.Float(
|
||||
string='Temperature',
|
||||
default=0.7,
|
||||
help='Contrôle la créativité des réponses (0.0 - 1.0)',
|
||||
tracking=True
|
||||
)
|
||||
max_tokens = fields.Integer(
|
||||
string='Max Tokens',
|
||||
default=2000,
|
||||
tracking=True
|
||||
)
|
||||
active = fields.Boolean(default=True, tracking=True)
|
||||
|
||||
_sql_constraints = [
|
||||
('company_uniq', 'unique(company_id, active)',
|
||||
'Une seule configuration active par compagnie est autorisée!')
|
||||
]
|
||||
|
||||
@api.model
|
||||
def get_default_config(self):
|
||||
"""Récupère la configuration active pour la compagnie actuelle"""
|
||||
config = self.search([
|
||||
('active', '=', True),
|
||||
('company_id', '=', self.env.company.id)
|
||||
], limit=1)
|
||||
if not config:
|
||||
raise UserError('Aucune configuration AI active trouvée pour votre compagnie')
|
||||
62
interactive_discuss_ai/models/ai_service.py
Normal file
62
interactive_discuss_ai/models/ai_service.py
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
import requests
|
||||
import json
|
||||
from odoo import models, tools
|
||||
from odoo.exceptions import UserError
|
||||
|
||||
class AIService(models.AbstractModel):
|
||||
_name = 'ai.service'
|
||||
_description = 'AI Service for Open WebUI Integration'
|
||||
|
||||
def _get_headers(self):
|
||||
return {
|
||||
'Content-Type': 'application/json',
|
||||
}
|
||||
|
||||
def _call_openwebui_api(self, endpoint, payload):
|
||||
"""Appelle l'API Open WebUI avec la configuration de la compagnie actuelle"""
|
||||
config = self.env['ai.config'].with_company(self.env.company).get_default_config()
|
||||
url = f"{config.openwebui_url}/api/v1/{endpoint}"
|
||||
|
||||
try:
|
||||
response = requests.post(
|
||||
url,
|
||||
headers=self._get_headers(),
|
||||
json=payload,
|
||||
timeout=30
|
||||
)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
except requests.exceptions.RequestException as e:
|
||||
raise UserError(f"Erreur de communication avec Open WebUI: {str(e)}")
|
||||
|
||||
def generate_response(self, user_input, conversation_history=None):
|
||||
"""Génère une réponse en utilisant le modèle Open WebUI de la compagnie"""
|
||||
config = self.env['ai.config'].with_company(self.env.company).get_default_config()
|
||||
|
||||
messages = []
|
||||
if config.system_prompt:
|
||||
messages.append({
|
||||
"role": "system",
|
||||
"content": config.system_prompt
|
||||
})
|
||||
|
||||
# Ajouter l'historique de conversation si disponible
|
||||
if conversation_history:
|
||||
messages.extend(conversation_history)
|
||||
|
||||
# Ajouter l'entrée utilisateur actuelle
|
||||
messages.append({
|
||||
"role": "user",
|
||||
"content": user_input
|
||||
})
|
||||
|
||||
payload = {
|
||||
"model": config.model_name,
|
||||
"messages": messages,
|
||||
"temperature": config.temperature,
|
||||
"max_tokens": config.max_tokens,
|
||||
"stream": False
|
||||
}
|
||||
|
||||
response = self._call_openwebui_api('chat/completions', payload)
|
||||
return response.get('choices', [{}])[0].get('message', {}).get('content', '')
|
||||
3
interactive_discuss_ai/security/ir.model.access.csv
Normal file
3
interactive_discuss_ai/security/ir.model.access.csv
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
|
||||
access_ai_assistant_channel_user,ai.assistant.channel.user,interactive_discuss_ai.model_ai_assistant_channel,base.group_user,1,1,1,1
|
||||
access_ai_config_user,ai.config.user,interactive_discuss_ai.model_ai_config,base.group_user,1,1,1,1
|
||||
|
52
interactive_discuss_ai/static/src/js/ai_assistant_chat.js
Normal file
52
interactive_discuss_ai/static/src/js/ai_assistant_chat.js
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
/** @odoo-module **/
|
||||
|
||||
import { registerPatch } from '@mail/model/model_core';
|
||||
import { attr } from '@mail/model/model_field';
|
||||
import { clear } from '@mail/model/model_field_command';
|
||||
|
||||
registerPatch({
|
||||
name: 'mail.composer',
|
||||
fields: {
|
||||
isVoiceRecording: attr({
|
||||
default: false,
|
||||
}),
|
||||
},
|
||||
recordMethods: {
|
||||
async onClickVoiceRecord() {
|
||||
if (!this.isVoiceRecording) {
|
||||
try {
|
||||
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||
this.mediaRecorder = new MediaRecorder(stream);
|
||||
const audioChunks = [];
|
||||
|
||||
this.mediaRecorder.addEventListener('dataavailable', (event) => {
|
||||
audioChunks.push(event.data);
|
||||
});
|
||||
|
||||
this.mediaRecorder.addEventListener('stop', async () => {
|
||||
const audioBlob = new Blob(audioChunks);
|
||||
// Convert to base64 and send to backend
|
||||
const reader = new FileReader();
|
||||
reader.readAsDataURL(audioBlob);
|
||||
reader.onloadend = async () => {
|
||||
const base64data = reader.result;
|
||||
await this.env.services.rpc({
|
||||
model: 'ai.assistant.channel',
|
||||
method: 'process_voice_message',
|
||||
args: [this.thread.id, base64data],
|
||||
});
|
||||
};
|
||||
});
|
||||
|
||||
this.mediaRecorder.start();
|
||||
this.update({ isVoiceRecording: true });
|
||||
} catch (err) {
|
||||
console.error('Error accessing microphone:', err);
|
||||
}
|
||||
} else {
|
||||
this.mediaRecorder.stop();
|
||||
this.update({ isVoiceRecording: false });
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
12
interactive_discuss_ai/static/src/xml/ai_assistant_chat.xml
Normal file
12
interactive_discuss_ai/static/src/xml/ai_assistant_chat.xml
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates xml:space="preserve">
|
||||
<t t-inherit="mail.Composer" t-inherit-mode="extension">
|
||||
<xpath expr="//div[hasclass('o-mail-Composer-buttons')]" position="inside">
|
||||
<button class="btn btn-light"
|
||||
t-on-click="onClickVoiceRecord"
|
||||
t-att-class="{ 'btn-danger': isVoiceRecording }">
|
||||
<i class="fa fa-microphone" t-att-class="{ 'fa-pulse': isVoiceRecording }"/>
|
||||
</button>
|
||||
</xpath>
|
||||
</t>
|
||||
</templates>
|
||||
86
interactive_discuss_ai/views/ai_assistant_views.xml
Normal file
86
interactive_discuss_ai/views/ai_assistant_views.xml
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<!-- AI Assistant Channel Form View -->
|
||||
<record id="view_ai_assistant_channel_form" model="ir.ui.view">
|
||||
<field name="name">ai.assistant.channel.form</field>
|
||||
<field name="model">ai.assistant.channel</field>
|
||||
<field name="arch" type="xml">
|
||||
<form string="AI Assistant Channel">
|
||||
<sheet>
|
||||
<div class="oe_button_box" name="button_box">
|
||||
<button name="toggle_active" type="object" class="oe_stat_button" icon="fa-archive">
|
||||
<field name="active" widget="boolean_button" options="{"terminology": "archive"}"/>
|
||||
</button>
|
||||
</div>
|
||||
<group>
|
||||
<group>
|
||||
<field name="name"/>
|
||||
<field name="user_id"/>
|
||||
<field name="company_id" groups="base.group_multi_company"/>
|
||||
</group>
|
||||
</group>
|
||||
</sheet>
|
||||
<div class="oe_chatter">
|
||||
<field name="message_follower_ids"/>
|
||||
<field name="message_ids"/>
|
||||
</div>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- AI Assistant Channel Tree View -->
|
||||
<record id="view_ai_assistant_channel_tree" model="ir.ui.view">
|
||||
<field name="name">ai.assistant.channel.tree</field>
|
||||
<field name="model">ai.assistant.channel</field>
|
||||
<field name="arch" type="xml">
|
||||
<tree string="AI Assistant Channels">
|
||||
<field name="name"/>
|
||||
<field name="user_id"/>
|
||||
<field name="company_id" groups="base.group_multi_company"/>
|
||||
</tree>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- AI Assistant Channel Search View -->
|
||||
<record id="view_ai_assistant_channel_search" model="ir.ui.view">
|
||||
<field name="name">ai.assistant.channel.search</field>
|
||||
<field name="model">ai.assistant.channel</field>
|
||||
<field name="arch" type="xml">
|
||||
<search string="Search AI Assistant Channels">
|
||||
<field name="name"/>
|
||||
<field name="user_id"/>
|
||||
<field name="company_id" groups="base.group_multi_company"/>
|
||||
<separator/>
|
||||
<filter string="Archived" name="inactive" domain="[('active', '=', False)]"/>
|
||||
</search>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- AI Assistant Channel Action -->
|
||||
<record id="action_ai_assistant_channel" model="ir.actions.act_window">
|
||||
<field name="name">AI Assistant Channels</field>
|
||||
<field name="res_model">ai.assistant.channel</field>
|
||||
<field name="view_mode">tree,form</field>
|
||||
<field name="search_view_id" ref="view_ai_assistant_channel_search"/>
|
||||
<field name="help" type="html">
|
||||
<p class="o_view_nocontent_smiling_face">
|
||||
Create your first AI Assistant Channel!
|
||||
</p>
|
||||
<p>
|
||||
AI Assistant Channels allow you to interact with the AI assistant through messages.
|
||||
</p>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- Menu Items -->
|
||||
<menuitem id="menu_ai_assistant"
|
||||
name="AI Assistant"
|
||||
web_icon="interactive_discuss_ai,static/description/icon.png"
|
||||
sequence="10"/>
|
||||
|
||||
<menuitem id="menu_ai_assistant_channel"
|
||||
name="Assistant Channels"
|
||||
parent="menu_ai_assistant"
|
||||
action="action_ai_assistant_channel"
|
||||
sequence="10"/>
|
||||
</odoo>
|
||||
83
interactive_discuss_ai/views/ai_config_views.xml
Normal file
83
interactive_discuss_ai/views/ai_config_views.xml
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<record id="view_ai_config_form" model="ir.ui.view">
|
||||
<field name="name">ai.config.form</field>
|
||||
<field name="model">ai.config</field>
|
||||
<field name="arch" type="xml">
|
||||
<form>
|
||||
<sheet>
|
||||
<div class="oe_button_box" name="button_box">
|
||||
<button name="toggle_active" type="object" class="oe_stat_button" icon="fa-archive">
|
||||
<field name="active" widget="boolean_button"/>
|
||||
</button>
|
||||
</div>
|
||||
<group>
|
||||
<group>
|
||||
<field name="name"/>
|
||||
<field name="company_id" groups="base.group_multi_company"/>
|
||||
<field name="openwebui_url"/>
|
||||
<field name="model_name"/>
|
||||
<field name="active"/>
|
||||
</group>
|
||||
<group>
|
||||
<field name="temperature"/>
|
||||
<field name="max_tokens"/>
|
||||
</group>
|
||||
</group>
|
||||
<group string="System Prompt">
|
||||
<field name="system_prompt" nolabel="1"/>
|
||||
</group>
|
||||
</sheet>
|
||||
<div class="oe_chatter">
|
||||
<field name="message_follower_ids"/>
|
||||
<field name="message_ids"/>
|
||||
</div>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="view_ai_config_tree" model="ir.ui.view">
|
||||
<field name="name">ai.config.tree</field>
|
||||
<field name="model">ai.config</field>
|
||||
<field name="arch" type="xml">
|
||||
<tree>
|
||||
<field name="name"/>
|
||||
<field name="company_id" groups="base.group_multi_company"/>
|
||||
<field name="model_name"/>
|
||||
<field name="openwebui_url"/>
|
||||
<field name="active"/>
|
||||
</tree>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="view_ai_config_search" model="ir.ui.view">
|
||||
<field name="name">ai.config.search</field>
|
||||
<field name="model">ai.config</field>
|
||||
<field name="arch" type="xml">
|
||||
<search>
|
||||
<field name="name"/>
|
||||
<field name="company_id" groups="base.group_multi_company"/>
|
||||
<field name="model_name"/>
|
||||
<separator/>
|
||||
<filter string="Archivé" name="inactive" domain="[('active', '=', False)]"/>
|
||||
<group expand="0" string="Group By">
|
||||
<filter string="Compagnie" name="company" domain="[]" context="{'group_by': 'company_id'}"/>
|
||||
</group>
|
||||
</search>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="action_ai_config" model="ir.actions.act_window">
|
||||
<field name="name">Configuration AI</field>
|
||||
<field name="res_model">ai.config</field>
|
||||
<field name="view_mode">tree,form</field>
|
||||
<field name="search_view_id" ref="view_ai_config_search"/>
|
||||
<field name="context">{'search_default_active': 1}</field>
|
||||
</record>
|
||||
|
||||
<menuitem id="menu_ai_config"
|
||||
name="Configuration AI"
|
||||
action="action_ai_config"
|
||||
parent="base.menu_administration"
|
||||
sequence="100"/>
|
||||
</odoo>
|
||||
3
mail_manual_routing_ai/__init__.py
Normal file
3
mail_manual_routing_ai/__init__.py
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
|
||||
from . import models
|
||||
36
mail_manual_routing_ai/__manifest__.py
Normal file
36
mail_manual_routing_ai/__manifest__.py
Normal file
|
|
@ -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",
|
||||
}
|
||||
258
mail_manual_routing_ai/doc/ai-enhanced.md
Normal file
258
mail_manual_routing_ai/doc/ai-enhanced.md
Normal file
|
|
@ -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
|
||||
<!-- views/mail_message_view.xml -->
|
||||
<record id="mail_message_view_form_ai_enhanced" model="ir.ui.view">
|
||||
<field name="model">mail.message</field>
|
||||
<field name="inherit_id" ref="mail_manual_routing.mail_message_view_form"/>
|
||||
<field name="arch" type="xml">
|
||||
<xpath expr="//header" position="inside">
|
||||
<button string="Analyser avec IA"
|
||||
name="action_analyze_with_ai"
|
||||
type="object"
|
||||
class="btn-primary"
|
||||
attrs="{'invisible': ['|', ('is_unattached', '=', False), ('ai_analyzed', '=', True)]}"/>
|
||||
</xpath>
|
||||
<xpath expr="//field[@name='lost_comments']" position="after">
|
||||
<field name="ai_analyzed" invisible="1"/>
|
||||
<field name="ai_suggestion_count" invisible="1"/>
|
||||
<div class="alert alert-info" attrs="{'invisible': [('ai_suggestion_count', '=', 0)]}">
|
||||
<div class="d-flex align-items-center">
|
||||
<i class="fa fa-robot mr-2"/>
|
||||
<span>L'IA suggère de router ce message vers : </span>
|
||||
<field name="ai_top_suggestion_id" readonly="1" options="{'no_open': True}"/>
|
||||
<button string="Appliquer"
|
||||
name="action_apply_ai_suggestion"
|
||||
type="object"
|
||||
class="btn btn-sm btn-primary ml-2"
|
||||
attrs="{'invisible': [('ai_top_suggestion_id', '=', False)]}"/>
|
||||
<button string="Voir toutes les suggestions"
|
||||
name="action_view_ai_suggestions"
|
||||
type="object"
|
||||
class="btn btn-sm btn-secondary ml-2"
|
||||
attrs="{'invisible': [('ai_suggestion_count', '=', 0)]}"/>
|
||||
</div>
|
||||
<div attrs="{'invisible': [('ai_top_suggestion_id', '=', False)]}">
|
||||
<field name="ai_top_suggestion_id.suggestion_reason" readonly="1" class="text-muted"/>
|
||||
</div>
|
||||
</div>
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
```
|
||||
|
||||
## 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.
|
||||
6
mail_manual_routing_ai/models/__init__.py
Normal file
6
mail_manual_routing_ai/models/__init__.py
Normal file
|
|
@ -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
|
||||
103
mail_manual_routing_ai/models/mail_message.py
Normal file
103
mail_manual_routing_ai/models/mail_message.py
Normal file
|
|
@ -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
|
||||
236
mail_manual_routing_ai/models/mail_message_ai_analyzer.py
Normal file
236
mail_manual_routing_ai/models/mail_message_ai_analyzer.py
Normal file
|
|
@ -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
|
||||
321
mail_manual_routing_ai/models/mail_message_ai_openwebui.py
Normal file
321
mail_manual_routing_ai/models/mail_message_ai_openwebui.py
Normal file
|
|
@ -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('<br>', '\n').replace('<p>', '\n').replace('</p>', '\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 = _("""
|
||||
<p><strong>Analyse IA du message perdu</strong></p>
|
||||
<ul>
|
||||
<li><strong>Type de demande:</strong> %(type)s</li>
|
||||
<li><strong>Urgence:</strong> %(urgence)s</li>
|
||||
<li><strong>Département concerné:</strong> %(departement)s</li>
|
||||
<li><strong>Résumé:</strong> %(resume)s</li>
|
||||
</ul>
|
||||
<p><strong>Actions recommandées:</strong></p>
|
||||
<ul>
|
||||
%(actions)s
|
||||
</ul>
|
||||
<p><strong>Mots-clés:</strong> %(mots_cles)s</p>
|
||||
<p><a href="/mail/view?model=lost.message.parent&res_id=%(parent_id)s">Voir le message</a></p>
|
||||
""") % {
|
||||
'type': analysis.get('type_demande', 'Non identifié'),
|
||||
'urgence': analysis.get('urgence', 'Non identifié'),
|
||||
'departement': analysis.get('departement', 'Non identifié'),
|
||||
'resume': analysis.get('resume', 'Non disponible'),
|
||||
'actions': ''.join([f"<li>{action}</li>" for action in analysis.get('actions_recommandees', ['Aucune action suggérée'])]),
|
||||
'mots_cles': ', '.join(analysis.get('mots_cles', ['Aucun'])),
|
||||
'parent_id': parent.id,
|
||||
}
|
||||
|
||||
# Création de l'activité
|
||||
activity_values = {
|
||||
'activity_type_id': self.env.ref('mail.mail_activity_data_todo').id,
|
||||
'summary': summary,
|
||||
'note': note,
|
||||
'user_id': responsible_user_id,
|
||||
'res_id': parent.id,
|
||||
'res_model_id': self.env['ir.model'].sudo().search([('model', '=', 'lost.message.parent')], limit=1).id,
|
||||
'date_deadline': date_deadline,
|
||||
}
|
||||
|
||||
# Vérifier si le champ priority existe dans le modèle mail.activity
|
||||
if 'priority' in self.env['mail.activity']._fields:
|
||||
activity_values['priority'] = priority
|
||||
|
||||
activity = self.env['mail.activity'].create(activity_values)
|
||||
|
||||
return activity
|
||||
76
mail_manual_routing_ai/models/mail_message_ai_suggestion.py
Normal file
76
mail_manual_routing_ai/models/mail_message_ai_suggestion.py
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
|
||||
from odoo import api, fields, models, _
|
||||
|
||||
|
||||
class MailMessageAISuggestion(models.Model):
|
||||
_name = "mail.message.ai.suggestion"
|
||||
_description = "Suggestions IA pour messages perdus"
|
||||
_order = "confidence_score desc, id desc"
|
||||
|
||||
message_id = fields.Many2one('mail.message', string="Message", required=True, ondelete='cascade')
|
||||
suggested_model = fields.Char(string="Modèle suggéré")
|
||||
suggested_record_id = fields.Integer(string="ID d'enregistrement suggéré")
|
||||
suggested_record_name = fields.Char(string="Nom de l'enregistrement", compute="_compute_suggested_record_name", store=True)
|
||||
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")
|
||||
date_created = fields.Datetime(string="Date de création", default=fields.Datetime.now)
|
||||
|
||||
@api.depends('suggested_model', 'suggested_record_id')
|
||||
def _compute_suggested_record_name(self):
|
||||
"""Récupère le nom de l'enregistrement suggéré pour l'affichage"""
|
||||
for suggestion in self:
|
||||
if suggestion.suggested_model and suggestion.suggested_record_id:
|
||||
try:
|
||||
record = self.env[suggestion.suggested_model].sudo().browse(suggestion.suggested_record_id)
|
||||
if record.exists():
|
||||
suggestion.suggested_record_name = record.display_name
|
||||
else:
|
||||
suggestion.suggested_record_name = _("Enregistrement non trouvé")
|
||||
except Exception:
|
||||
suggestion.suggested_record_name = _("Modèle non valide")
|
||||
else:
|
||||
suggestion.suggested_record_name = False
|
||||
|
||||
def name_get(self):
|
||||
"""Personnalise l'affichage du nom des suggestions"""
|
||||
result = []
|
||||
for suggestion in self:
|
||||
name = f"{suggestion.suggested_model or '?'} - {suggestion.suggested_record_name or '?'} ({int(suggestion.confidence_score * 100)}%)"
|
||||
result.append((suggestion.id, name))
|
||||
return result
|
||||
|
||||
def action_apply_suggestion(self):
|
||||
"""Applique la suggestion en attachant le message à l'enregistrement suggéré"""
|
||||
self.ensure_one()
|
||||
if not self.suggested_model or not self.suggested_record_id:
|
||||
return False
|
||||
|
||||
# Utilise le wizard de mail_manual_routing pour attacher le message
|
||||
wizard = self.env['mail.message.attach.wizard'].create({
|
||||
'message_ids': [(6, 0, [self.message_id.id])],
|
||||
'model': self.suggested_model,
|
||||
'res_id': self.suggested_record_id,
|
||||
})
|
||||
result = wizard.action_attach_mail_message()
|
||||
|
||||
# Marque la suggestion comme appliquée et enregistre un feedback positif
|
||||
self.write({
|
||||
'is_applied': True,
|
||||
'user_feedback': 'positive',
|
||||
})
|
||||
|
||||
return result
|
||||
|
||||
def action_mark_feedback(self, feedback_type):
|
||||
"""Enregistre le feedback de l'utilisateur sur la suggestion"""
|
||||
self.ensure_one()
|
||||
self.write({
|
||||
'user_feedback': feedback_type,
|
||||
})
|
||||
return True
|
||||
70
mail_manual_routing_ai/models/res_config_settings.py
Normal file
70
mail_manual_routing_ai/models/res_config_settings.py
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
|
||||
from odoo import api, fields, models
|
||||
|
||||
|
||||
class ResConfigSettings(models.TransientModel):
|
||||
_inherit = 'res.config.settings'
|
||||
|
||||
ai_auto_analyze_enabled = fields.Boolean(
|
||||
string="Analyse automatique des messages perdus",
|
||||
config_parameter="mail_manual_routing_ai.auto_analyze_enabled",
|
||||
default=True,
|
||||
help="Analyser automatiquement les messages perdus avec l'IA"
|
||||
)
|
||||
|
||||
ai_auto_routing_enabled = fields.Boolean(
|
||||
string="Routage automatique",
|
||||
config_parameter="mail_manual_routing_ai.auto_routing_enabled",
|
||||
default=False,
|
||||
help="Router automatiquement les messages selon les suggestions de l'IA si le score de confiance est suffisant"
|
||||
)
|
||||
|
||||
ai_auto_routing_threshold = fields.Float(
|
||||
string="Seuil de confiance pour 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_models_allowed = fields.Char(
|
||||
string="Modèles autorisés pour le routage IA",
|
||||
config_parameter="mail_manual_routing_ai.models_allowed",
|
||||
default="sale.order,project.project,project.task,res.partner",
|
||||
help="Liste de modèles séparés par des virgules autorisés pour le routage IA"
|
||||
)
|
||||
|
||||
ai_learning_enabled = fields.Boolean(
|
||||
string="Apprentissage continu",
|
||||
config_parameter="mail_manual_routing_ai.learning_enabled",
|
||||
default=True,
|
||||
help="Utiliser les retours utilisateurs pour améliorer les futures suggestions"
|
||||
)
|
||||
|
||||
# Paramètres OpenWebUI
|
||||
ai_use_openwebui = fields.Boolean(
|
||||
string="Utiliser OpenWebUI",
|
||||
config_parameter='mail_manual_routing_ai.use_openwebui'
|
||||
)
|
||||
|
||||
ai_openwebui_endpoint = fields.Char(
|
||||
string="URL de l'API OpenWebUI",
|
||||
config_parameter="mail_manual_routing_ai.openwebui_endpoint",
|
||||
default="http://localhost:3000/api/chat",
|
||||
help="URL de l'API OpenWebUI pour l'analyse des messages"
|
||||
)
|
||||
|
||||
ai_openwebui_api_key = fields.Char(
|
||||
string="Clé API OpenWebUI",
|
||||
config_parameter="mail_manual_routing_ai.openwebui_api_key",
|
||||
default="",
|
||||
help="Clé API pour l'authentification avec OpenWebUI"
|
||||
)
|
||||
|
||||
ai_responsible_user_id = fields.Many2one(
|
||||
'res.users',
|
||||
string="Utilisateur responsable des messages perdus",
|
||||
config_parameter="mail_manual_routing_ai.responsible_user_id",
|
||||
default=lambda self: self.env.ref('base.user_admin').id,
|
||||
help="Utilisateur qui recevra les activités pour les messages perdus analysés"
|
||||
)
|
||||
2
mail_manual_routing_ai/security/ir.model.access.csv
Normal file
2
mail_manual_routing_ai/security/ir.model.access.csv
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
|
||||
access_mail_message_ai_suggestion_user,mail.message.ai.suggestion.user,model_mail_message_ai_suggestion,mail_manual_routing.group_lost_messages,1,1,1,1
|
||||
|
108
mail_manual_routing_ai/views/mail_message_ai_suggestion_view.xml
Normal file
108
mail_manual_routing_ai/views/mail_message_ai_suggestion_view.xml
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<!-- Form View -->
|
||||
<record id="mail_message_ai_suggestion_view_form" model="ir.ui.view">
|
||||
<field name="name">mail.message.ai.suggestion.form</field>
|
||||
<field name="model">mail.message.ai.suggestion</field>
|
||||
<field name="arch" type="xml">
|
||||
<form string="Suggestion IA">
|
||||
<header>
|
||||
<button name="action_apply_suggestion" string="Appliquer cette suggestion"
|
||||
type="object" class="oe_highlight"
|
||||
attrs="{'invisible': [('is_applied', '=', True)]}"/>
|
||||
<button name="action_mark_feedback" string="Marquer comme correcte"
|
||||
type="object" class="btn-success"
|
||||
context="{'feedback_type': 'positive'}"
|
||||
attrs="{'invisible': [('user_feedback', '=', 'positive')]}"/>
|
||||
<button name="action_mark_feedback" string="Marquer comme incorrecte"
|
||||
type="object" class="btn-danger"
|
||||
context="{'feedback_type': 'negative'}"
|
||||
attrs="{'invisible': [('user_feedback', '=', 'negative')]}"/>
|
||||
</header>
|
||||
<sheet>
|
||||
<div class="oe_title">
|
||||
<label for="suggested_model" class="oe_edit_only"/>
|
||||
<h1>
|
||||
<field name="suggested_model" class="oe_inline"/> - <field name="suggested_record_name" class="oe_inline"/>
|
||||
</h1>
|
||||
</div>
|
||||
<group>
|
||||
<group>
|
||||
<field name="message_id"/>
|
||||
<field name="suggested_record_id"/>
|
||||
<field name="confidence_score" widget="percentage"/>
|
||||
</group>
|
||||
<group>
|
||||
<field name="is_applied"/>
|
||||
<field name="user_feedback"/>
|
||||
<field name="date_created"/>
|
||||
</group>
|
||||
</group>
|
||||
<notebook>
|
||||
<page string="Explication">
|
||||
<field name="suggestion_reason"/>
|
||||
</page>
|
||||
</notebook>
|
||||
</sheet>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- Tree View -->
|
||||
<record id="mail_message_ai_suggestion_view_tree" model="ir.ui.view">
|
||||
<field name="name">mail.message.ai.suggestion.tree</field>
|
||||
<field name="model">mail.message.ai.suggestion</field>
|
||||
<field name="arch" type="xml">
|
||||
<tree string="Suggestions IA" decoration-success="is_applied" decoration-info="user_feedback == 'positive'" decoration-danger="user_feedback == 'negative'">
|
||||
<field name="message_id"/>
|
||||
<field name="suggested_model"/>
|
||||
<field name="suggested_record_name"/>
|
||||
<field name="confidence_score" widget="percentage"/>
|
||||
<field name="is_applied"/>
|
||||
<field name="user_feedback"/>
|
||||
<field name="date_created"/>
|
||||
<button name="action_apply_suggestion" string="Appliquer" type="object" icon="fa-check" attrs="{'invisible': [('is_applied', '=', True)]}"/>
|
||||
</tree>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- Search View -->
|
||||
<record id="mail_message_ai_suggestion_view_search" model="ir.ui.view">
|
||||
<field name="name">mail.message.ai.suggestion.search</field>
|
||||
<field name="model">mail.message.ai.suggestion</field>
|
||||
<field name="arch" type="xml">
|
||||
<search string="Rechercher des suggestions">
|
||||
<field name="message_id"/>
|
||||
<field name="suggested_model"/>
|
||||
<field name="suggested_record_name"/>
|
||||
<filter string="Appliquées" name="applied" domain="[('is_applied', '=', True)]"/>
|
||||
<filter string="Non appliquées" name="not_applied" domain="[('is_applied', '=', False)]"/>
|
||||
<filter string="Feedback positif" name="positive_feedback" domain="[('user_feedback', '=', 'positive')]"/>
|
||||
<filter string="Feedback négatif" name="negative_feedback" domain="[('user_feedback', '=', 'negative')]"/>
|
||||
<filter string="Score élevé (>75%)" name="high_score" domain="[('confidence_score', '>=', 0.75)]"/>
|
||||
<group expand="0" string="Grouper par">
|
||||
<filter string="Message" name="group_by_message" context="{'group_by': 'message_id'}"/>
|
||||
<filter string="Modèle suggéré" name="group_by_model" context="{'group_by': 'suggested_model'}"/>
|
||||
<filter string="Appliqué" name="group_by_applied" context="{'group_by': 'is_applied'}"/>
|
||||
<filter string="Feedback" name="group_by_feedback" context="{'group_by': 'user_feedback'}"/>
|
||||
</group>
|
||||
</search>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- Action -->
|
||||
<record id="action_mail_message_ai_suggestion" model="ir.actions.act_window">
|
||||
<field name="name">Suggestions IA</field>
|
||||
<field name="res_model">mail.message.ai.suggestion</field>
|
||||
<field name="view_mode">tree,form</field>
|
||||
<field name="context">{'search_default_not_applied': 1}</field>
|
||||
<field name="help" type="html">
|
||||
<p class="o_view_nocontent_smiling_face">
|
||||
Aucune suggestion IA pour le moment
|
||||
</p>
|
||||
<p>
|
||||
Les suggestions apparaîtront ici après l'analyse des messages non routés.
|
||||
</p>
|
||||
</field>
|
||||
</record>
|
||||
</odoo>
|
||||
70
mail_manual_routing_ai/views/mail_message_view.xml
Normal file
70
mail_manual_routing_ai/views/mail_message_view.xml
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<!-- Extend mail.message form view -->
|
||||
<record id="mail_message_view_form_ai_enhanced" model="ir.ui.view">
|
||||
<field name="name">mail.message.form.ai.enhanced</field>
|
||||
<field name="model">mail.message</field>
|
||||
<field name="inherit_id" ref="mail_manual_routing.mail_message_view_form"/>
|
||||
<field name="arch" type="xml">
|
||||
<xpath expr="//header" position="inside">
|
||||
<button string="Analyser avec IA"
|
||||
name="action_analyze_with_ai"
|
||||
type="object"
|
||||
class="btn-primary"
|
||||
attrs="{'invisible': ['|', ('is_unattached', '=', False), ('ai_analyzed', '=', True)]}"/>
|
||||
</xpath>
|
||||
<xpath expr="//field[@name='lost_comments']" position="after">
|
||||
<field name="ai_analyzed" invisible="1"/>
|
||||
<field name="ai_suggestion_count" invisible="1"/>
|
||||
<field name="ai_top_suggestion_id" invisible="1"/>
|
||||
<div class="alert alert-info" attrs="{'invisible': [('ai_suggestion_count', '=', 0)]}">
|
||||
<div class="d-flex align-items-center">
|
||||
<i class="fa fa-robot mr-2"/>
|
||||
<span>L'IA suggère de router ce message vers : </span>
|
||||
<field name="ai_top_suggestion_id" readonly="1" options="{'no_open': True}"/>
|
||||
<button string="Appliquer"
|
||||
name="action_apply_ai_suggestion"
|
||||
type="object"
|
||||
class="btn btn-sm btn-primary ml-2"
|
||||
attrs="{'invisible': [('ai_top_suggestion_id', '=', False)]}"/>
|
||||
<button string="Voir toutes les suggestions"
|
||||
name="action_view_ai_suggestions"
|
||||
type="object"
|
||||
class="btn btn-sm btn-secondary ml-2"
|
||||
attrs="{'invisible': [('ai_suggestion_count', '=', 0)]}"/>
|
||||
</div>
|
||||
<div attrs="{'invisible': [('ai_top_suggestion_id', '=', False)]}">
|
||||
<field name="ai_top_suggestion_id.suggestion_reason" readonly="1" class="text-muted"/>
|
||||
</div>
|
||||
</div>
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- Extend mail.message tree view -->
|
||||
<record id="mail_message_view_tree_ai_enhanced" model="ir.ui.view">
|
||||
<field name="name">mail.message.tree.ai.enhanced</field>
|
||||
<field name="model">mail.message</field>
|
||||
<field name="inherit_id" ref="mail_manual_routing.mail_message_view_tree"/>
|
||||
<field name="arch" type="xml">
|
||||
<xpath expr="//field[@name='is_unattached']" position="after">
|
||||
<field name="ai_analyzed"/>
|
||||
<field name="ai_suggestion_count"/>
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- Extend mail.message search view -->
|
||||
<record id="mail_message_view_search_ai_enhanced" model="ir.ui.view">
|
||||
<field name="name">mail.message.search.ai.enhanced</field>
|
||||
<field name="model">mail.message</field>
|
||||
<field name="inherit_id" ref="mail_manual_routing.mail_message_view_search"/>
|
||||
<field name="arch" type="xml">
|
||||
<xpath expr="//filter[@name='unattached']" position="after">
|
||||
<filter string="Analysés par IA" name="ai_analyzed" domain="[('ai_analyzed', '=', True)]"/>
|
||||
<filter string="Non analysés par IA" name="not_ai_analyzed" domain="[('is_unattached', '=', True), ('ai_analyzed', '=', False)]"/>
|
||||
<filter string="Avec suggestions" name="with_suggestions" domain="[('ai_suggestion_count', '>', 0)]"/>
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
</odoo>
|
||||
10
mail_manual_routing_ai/views/menu.xml
Normal file
10
mail_manual_routing_ai/views/menu.xml
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<!-- Menu items -->
|
||||
<menuitem id="menu_mail_message_ai_suggestion"
|
||||
name="Suggestions IA"
|
||||
parent="mail_manual_routing.menu_lost_messages"
|
||||
action="action_mail_message_ai_suggestion"
|
||||
sequence="20"
|
||||
groups="mail_manual_routing.group_lost_messages"/>
|
||||
</odoo>
|
||||
108
mail_manual_routing_ai/views/res_config_settings_view.xml
Normal file
108
mail_manual_routing_ai/views/res_config_settings_view.xml
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<record id="res_config_settings_view_form_ai_enhanced" model="ir.ui.view">
|
||||
<field name="name">res.config.settings.view.form.ai.enhanced</field>
|
||||
<field name="model">res.config.settings</field>
|
||||
<field name="inherit_id" ref="base.res_config_settings_view_form"/>
|
||||
<field name="arch" type="xml">
|
||||
<xpath expr="//div[@id='lost_messages_settings']" position="after">
|
||||
<h2>Paramètres d'IA pour les messages perdus</h2>
|
||||
<div class="row mt16 o_settings_container" id="ai_lost_messages_settings">
|
||||
<div class="col-12 col-lg-6 o_setting_box">
|
||||
<div class="o_setting_left_pane">
|
||||
<field name="ai_auto_analyze_enabled"/>
|
||||
</div>
|
||||
<div class="o_setting_right_pane">
|
||||
<label for="ai_auto_analyze_enabled"/>
|
||||
<div class="text-muted">
|
||||
Analyser automatiquement les messages perdus avec l'IA
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12 col-lg-6 o_setting_box">
|
||||
<div class="o_setting_left_pane">
|
||||
<field name="ai_auto_routing_enabled"/>
|
||||
</div>
|
||||
<div class="o_setting_right_pane">
|
||||
<label for="ai_auto_routing_enabled"/>
|
||||
<div class="text-muted">
|
||||
Router automatiquement les messages selon les suggestions de l'IA
|
||||
</div>
|
||||
<div class="content-group" attrs="{'invisible': [('ai_auto_routing_enabled', '=', False)]}">
|
||||
<div class="mt16">
|
||||
<label for="ai_auto_routing_threshold" class="o_light_label"/>
|
||||
<field name="ai_auto_routing_threshold" class="oe_inline" widget="percentage"/>
|
||||
</div>
|
||||
<div class="text-muted">
|
||||
Score de confiance minimum pour le routage automatique
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12 col-lg-6 o_setting_box">
|
||||
<div class="o_setting_left_pane">
|
||||
<field name="ai_learning_enabled"/>
|
||||
</div>
|
||||
<div class="o_setting_right_pane">
|
||||
<label for="ai_learning_enabled"/>
|
||||
<div class="text-muted">
|
||||
Utiliser les retours utilisateurs pour améliorer les futures suggestions
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12 col-lg-6 o_setting_box">
|
||||
<div class="o_setting_right_pane">
|
||||
<label for="ai_models_allowed"/>
|
||||
<field name="ai_models_allowed" placeholder="sale.order,project.project,res.partner"/>
|
||||
<div class="text-muted">
|
||||
Liste de modèles séparés par des virgules autorisés pour le routage IA
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2>Intégration OpenWebUI</h2>
|
||||
<div class="row mt16 o_settings_container" id="openwebui_settings">
|
||||
<div class="col-12 col-lg-6 o_setting_box">
|
||||
<div class="o_setting_left_pane">
|
||||
<field name="ai_use_openwebui"/>
|
||||
</div>
|
||||
<div class="o_setting_right_pane">
|
||||
<label for="ai_use_openwebui"/>
|
||||
<div class="text-muted">
|
||||
Utiliser OpenWebUI pour l'analyse des messages au lieu de l'analyseur standard
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12 col-lg-6 o_setting_box" attrs="{'invisible': [('ai_use_openwebui', '=', False)]}">
|
||||
<div class="o_setting_right_pane">
|
||||
<label for="ai_openwebui_endpoint"/>
|
||||
<field name="ai_openwebui_endpoint" placeholder="http://localhost:3000/api/chat"/>
|
||||
<div class="text-muted">
|
||||
URL de l'API OpenWebUI pour l'analyse des messages
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12 col-lg-6 o_setting_box" attrs="{'invisible': [('ai_use_openwebui', '=', False)]}">
|
||||
<div class="o_setting_right_pane">
|
||||
<label for="ai_openwebui_api_key"/>
|
||||
<field name="ai_openwebui_api_key" password="True"/>
|
||||
<div class="text-muted">
|
||||
Clé API pour l'authentification avec OpenWebUI
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12 col-lg-6 o_setting_box" attrs="{'invisible': [('ai_use_openwebui', '=', False)]}">
|
||||
<div class="o_setting_right_pane">
|
||||
<label for="ai_responsible_user_id"/>
|
||||
<field name="ai_responsible_user_id"/>
|
||||
<div class="text-muted">
|
||||
Utilisateur qui recevra les activités pour les messages perdus analysés
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
</odoo>
|
||||
1
nuke_mid_task/__init__.py
Normal file
1
nuke_mid_task/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
from . import models
|
||||
27
nuke_mid_task/__manifest__.py
Normal file
27
nuke_mid_task/__manifest__.py
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
{
|
||||
'name': 'Task Nuker',
|
||||
'version': '17.0.1.0.0',
|
||||
'category': 'Project',
|
||||
'summary': 'Add ability to nuke intermediate tasks while preserving hierarchy',
|
||||
'description': """
|
||||
This module adds a 'Nuke' button on tasks that have both parent and child tasks.
|
||||
When activated:
|
||||
- Requests confirmation
|
||||
- Reassigns all child tasks to the parent task
|
||||
- Deletes the intermediate task
|
||||
- Logs the action in the chatter of both parent and child tasks
|
||||
""",
|
||||
'author': 'DurPro',
|
||||
'website': 'https://www.durpro.com',
|
||||
'depends': [
|
||||
'project',
|
||||
],
|
||||
'data': [
|
||||
'views/project_task_views.xml',
|
||||
],
|
||||
'assets': {},
|
||||
'license': 'LGPL-3',
|
||||
'installable': True,
|
||||
'application': False,
|
||||
'auto_install': False,
|
||||
}
|
||||
1
nuke_mid_task/models/__init__.py
Normal file
1
nuke_mid_task/models/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
from . import project_task
|
||||
75
nuke_mid_task/models/project_task.py
Normal file
75
nuke_mid_task/models/project_task.py
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
from odoo import models, api, fields, _
|
||||
from odoo.exceptions import UserError
|
||||
|
||||
class ProjectTask(models.Model):
|
||||
_inherit = 'project.task'
|
||||
|
||||
can_be_nuked = fields.Boolean(
|
||||
compute='_compute_can_be_nuked',
|
||||
help="Technical field to determine if task can be nuked"
|
||||
)
|
||||
|
||||
@api.depends('parent_id', 'child_ids')
|
||||
def _compute_can_be_nuked(self):
|
||||
"""Compute if the task can be nuked based on parent and children presence"""
|
||||
for task in self:
|
||||
task.can_be_nuked = bool(task.parent_id and task.child_ids)
|
||||
|
||||
def action_nuke_task(self):
|
||||
"""
|
||||
Nuke the current task:
|
||||
1. Verify the task can be nuked
|
||||
2. Get parent and children
|
||||
3. Reassign all children to the parent
|
||||
4. Update children names to include nuked task name
|
||||
5. Post messages in the chatter
|
||||
6. Delete the current task
|
||||
7. Return action to redirect to parent task
|
||||
"""
|
||||
self.ensure_one()
|
||||
if not self.can_be_nuked:
|
||||
raise UserError(_("This task cannot be nuked. It must have both a parent and child tasks."))
|
||||
|
||||
parent = self.parent_id
|
||||
children = self.child_ids
|
||||
nuked_name = self.name
|
||||
|
||||
# Post message to parent about the upcoming changes
|
||||
parent.message_post(
|
||||
body=_(
|
||||
"The following task has been nuked: %(task)s\n"
|
||||
"%(count)d child tasks have been reassigned to this task."
|
||||
) % {
|
||||
'task': nuked_name,
|
||||
'count': len(children)
|
||||
}
|
||||
)
|
||||
|
||||
# Reassign all children to the parent, update their names and notify them
|
||||
for child in children:
|
||||
old_name = child.name
|
||||
# Update name to include nuked task's name
|
||||
child.write({
|
||||
'name': f"[{nuked_name}] {old_name}",
|
||||
'parent_id': parent.id
|
||||
})
|
||||
child.message_post(
|
||||
body=_("This task has been reassigned to %(new_parent)s due to the removal of %(old_parent)s.\nTask name updated from '%(old_name)s' to '%(new_name)s'") % {
|
||||
'new_parent': parent.name,
|
||||
'old_parent': nuked_name,
|
||||
'old_name': old_name,
|
||||
'new_name': child.name
|
||||
}
|
||||
)
|
||||
|
||||
# Delete the current task
|
||||
self.unlink()
|
||||
|
||||
# Return action to redirect to parent task
|
||||
return {
|
||||
'type': 'ir.actions.act_window',
|
||||
'res_model': 'project.task',
|
||||
'res_id': parent.id,
|
||||
'view_mode': 'form',
|
||||
'target': 'current',
|
||||
}
|
||||
22
nuke_mid_task/views/project_task_views.xml
Normal file
22
nuke_mid_task/views/project_task_views.xml
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<record id="view_task_form_inherit_nuke" model="ir.ui.view">
|
||||
<field name="name">project.task.form.inherit.nuke</field>
|
||||
<field name="model">project.task</field>
|
||||
<field name="inherit_id" ref="project.view_task_form2"/>
|
||||
<field name="arch" type="xml">
|
||||
<xpath expr="//field[@name='recurrence_id']" position="after">
|
||||
<field name="can_be_nuked" invisible="1"/>
|
||||
</xpath>
|
||||
<xpath expr="//header" position="inside">
|
||||
<button
|
||||
name="action_nuke_task"
|
||||
string="Nuke Task"
|
||||
type="object"
|
||||
confirm="Are you sure you want to nuke this task? All child tasks will be reassigned to the parent task."
|
||||
invisible="not can_be_nuked"
|
||||
groups="project.group_project_manager"/>
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
</odoo>
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue