diff --git a/crm_lead_to_opportunity_contact/README.rst b/crm_lead_to_opportunity_contact/README.rst new file mode 100644 index 00000000000..5027bb0a557 --- /dev/null +++ b/crm_lead_to_opportunity_contact/README.rst @@ -0,0 +1,119 @@ +.. image:: https://odoo-community.org/readme-banner-image + :target: https://odoo-community.org/get-involved?utm_source=readme + :alt: Odoo Community Association + +================================= +CRM Lead to opportunity - Contact +================================= + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:4ed7fe64c973da857fdc999167004b63b97c43d00c2e48e4561aa75a4f73bc0e + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png + :target: https://odoo-community.org/page/development-status + :alt: Beta +.. |badge2| image:: https://img.shields.io/badge/license-AGPL--3-blue.png + :target: http://www.gnu.org/licenses/agpl-3.0-standalone.html + :alt: License: AGPL-3 +.. |badge3| image:: https://img.shields.io/badge/github-OCA%2Fcrm-lightgray.png?logo=github + :target: https://github.com/OCA/crm/tree/19.0/crm_lead_to_opportunity_contact + :alt: OCA/crm +.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png + :target: https://translation.odoo-community.org/projects/crm-19-0/crm-19-0-crm_lead_to_opportunity_contact + :alt: Translate me on Weblate +.. |badge5| image:: https://img.shields.io/badge/runboat-Try%20me-875A7B.png + :target: https://runboat.odoo-community.org/builds?repo=OCA/crm&target_branch=19.0 + :alt: Try me on Runboat + +|badge1| |badge2| |badge3| |badge4| |badge5| + +When converting a lead to an opportunity, the native wizard can already +attach the lead's contact to an existing company: pick **Create a new +customer** and fill the **Company** field. + +That field is only editable when the lead carries **no company name**, +though. As soon as the lead has one — which is the usual case for leads +coming from a website form or an import — the native wizard leaves no +way to do it: + +- **Create a new customer** creates a *second* company out of the lead's + company name, even when that company already exists; +- **Link to an existing customer** links the company, and the lead's + contact is never created. + +This module fills that gap. It adds a sub-question under **Link to an +existing customer**, shown when the lead has a contact name and the +selected customer is a company, to decide what to do with that contact: + +- **Create a new contact** on the existing company +- **Link to an existing contact** of that company +- **Do not add a contact** (keep the native behaviour: link the company, + and leave the lead's contact aside) + +If a contact of the company already matches the lead's email, it is +pre-selected, so that no duplicate is created. When the lead has no +contact name, or the selected customer is a person, the native behaviour +is kept. + +|Screenshot lead to opportunity wizard| + +.. |Screenshot lead to opportunity wizard| image:: https://raw.githubusercontent.com/OCA/crm/19.0/crm_lead_to_opportunity_contact/static/description/crm_lead_to_opportunity_contact-sshot.png + +**Table of contents** + +.. contents:: + :local: + +Bug Tracker +=========== + +Bugs are tracked on `GitHub Issues `_. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us to smash it by providing a detailed and welcomed +`feedback `_. + +Do not contact contributors directly about support or help with technical issues. + +Credits +======= + +Authors +------- + +* Akretion + +Contributors +------------ + +- Alexis de Lattre +- Marc Gimeno Morales + +Maintainers +----------- + +This module is maintained by the OCA. + +.. image:: https://odoo-community.org/logo.png + :alt: Odoo Community Association + :target: https://odoo-community.org + +OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use. + +.. |maintainer-alexis-via| image:: https://github.com/alexis-via.png?size=40px + :target: https://github.com/alexis-via + :alt: alexis-via + +Current `maintainer `__: + +|maintainer-alexis-via| + +This module is part of the `OCA/crm `_ project on GitHub. + +You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute. diff --git a/crm_lead_to_opportunity_contact/__init__.py b/crm_lead_to_opportunity_contact/__init__.py new file mode 100644 index 00000000000..aee8895e7a3 --- /dev/null +++ b/crm_lead_to_opportunity_contact/__init__.py @@ -0,0 +1,2 @@ +from . import models +from . import wizards diff --git a/crm_lead_to_opportunity_contact/__manifest__.py b/crm_lead_to_opportunity_contact/__manifest__.py new file mode 100644 index 00000000000..9fbe3320d08 --- /dev/null +++ b/crm_lead_to_opportunity_contact/__manifest__.py @@ -0,0 +1,20 @@ +# Copyright 2023 Akretion France (http://www.akretion.com/) +# @author: Alexis de Lattre +# Copyright 2026 ForgeFlow S.L. +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +{ + "name": "CRM Lead to opportunity - Contact", + "version": "19.0.1.0.0", + "category": "CRM", + "license": "AGPL-3", + "summary": "Lead to opportunity: create or link a contact on an existing customer", + "author": "Akretion,Odoo Community Association (OCA)", + "maintainers": ["alexis-via"], + "website": "https://github.com/OCA/crm", + "depends": ["crm"], + "data": [ + "wizards/crm_lead_to_opportunity_view.xml", + ], + "installable": True, +} diff --git a/crm_lead_to_opportunity_contact/models/__init__.py b/crm_lead_to_opportunity_contact/models/__init__.py new file mode 100644 index 00000000000..e66f0d6cf4e --- /dev/null +++ b/crm_lead_to_opportunity_contact/models/__init__.py @@ -0,0 +1 @@ +from . import crm_lead diff --git a/crm_lead_to_opportunity_contact/models/crm_lead.py b/crm_lead_to_opportunity_contact/models/crm_lead.py new file mode 100644 index 00000000000..5735fe1ff8e --- /dev/null +++ b/crm_lead_to_opportunity_contact/models/crm_lead.py @@ -0,0 +1,30 @@ +# Copyright 2023 Akretion France (http://www.akretion.com/) +# @author: Alexis de Lattre +# Copyright 2026 ForgeFlow S.L. +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo import models + + +class CrmLead(models.Model): + _inherit = "crm.lead" + + def _find_contact_in_company(self, company): + """Return a contact of ``company`` matching the lead's email, if any. + + Matching is done by email, which is the key Odoo itself uses to look up + partners, and the oldest one wins for the very same reason: it is + considered the most relevant. + """ + self.ensure_one() + if not company or not self.email_normalized: + return self.env["res.partner"] + return self.env["res.partner"].search( + [ + ("parent_id", "=", company.id), + ("is_company", "=", False), + ("email_normalized", "=", self.email_normalized), + ], + limit=1, + order="id", + ) diff --git a/crm_lead_to_opportunity_contact/pyproject.toml b/crm_lead_to_opportunity_contact/pyproject.toml new file mode 100644 index 00000000000..4231d0cccb3 --- /dev/null +++ b/crm_lead_to_opportunity_contact/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["whool"] +build-backend = "whool.buildapi" diff --git a/crm_lead_to_opportunity_contact/readme/CONTRIBUTORS.md b/crm_lead_to_opportunity_contact/readme/CONTRIBUTORS.md new file mode 100644 index 00000000000..b5ef89d84e1 --- /dev/null +++ b/crm_lead_to_opportunity_contact/readme/CONTRIBUTORS.md @@ -0,0 +1,2 @@ +- Alexis de Lattre \ +- Marc Gimeno Morales \ diff --git a/crm_lead_to_opportunity_contact/readme/DESCRIPTION.md b/crm_lead_to_opportunity_contact/readme/DESCRIPTION.md new file mode 100644 index 00000000000..64ea05be909 --- /dev/null +++ b/crm_lead_to_opportunity_contact/readme/DESCRIPTION.md @@ -0,0 +1,28 @@ +When converting a lead to an opportunity, the native wizard can already attach +the lead's contact to an existing company: pick **Create a new customer** and +fill the **Company** field. + +That field is only editable when the lead carries **no company name**, though. +As soon as the lead has one — which is the usual case for leads coming from a +website form or an import — the native wizard leaves no way to do it: + +- **Create a new customer** creates a *second* company out of the lead's company + name, even when that company already exists; +- **Link to an existing customer** links the company, and the lead's contact is + never created. + +This module fills that gap. It adds a sub-question under **Link to an existing +customer**, shown when the lead has a contact name and the selected customer is +a company, to decide what to do with that contact: + +- **Create a new contact** on the existing company +- **Link to an existing contact** of that company +- **Do not add a contact** (keep the native behaviour: link the company, and + leave the lead's contact aside) + +If a contact of the company already matches the lead's email, it is +pre-selected, so that no duplicate is created. When the lead has no contact +name, or the selected customer is a person, the native behaviour is kept. + +![Screenshot lead to opportunity +wizard](../static/description/crm_lead_to_opportunity_contact-sshot.png) diff --git a/crm_lead_to_opportunity_contact/static/description/crm_lead_to_opportunity_contact-sshot.png b/crm_lead_to_opportunity_contact/static/description/crm_lead_to_opportunity_contact-sshot.png new file mode 100644 index 00000000000..4e901a45d5c Binary files /dev/null and b/crm_lead_to_opportunity_contact/static/description/crm_lead_to_opportunity_contact-sshot.png differ diff --git a/crm_lead_to_opportunity_contact/static/description/icon.png b/crm_lead_to_opportunity_contact/static/description/icon.png new file mode 100644 index 00000000000..3a0328b516c Binary files /dev/null and b/crm_lead_to_opportunity_contact/static/description/icon.png differ diff --git a/crm_lead_to_opportunity_contact/static/description/index.html b/crm_lead_to_opportunity_contact/static/description/index.html new file mode 100644 index 00000000000..e0d822084f3 --- /dev/null +++ b/crm_lead_to_opportunity_contact/static/description/index.html @@ -0,0 +1,458 @@ + + + + + +README.rst + + + +
+ + + +Odoo Community Association + +
+

CRM Lead to opportunity - Contact

+ +

Beta License: AGPL-3 OCA/crm Translate me on Weblate Try me on Runboat

+

When converting a lead to an opportunity, the native wizard can already +attach the lead’s contact to an existing company: pick Create a new +customer and fill the Company field.

+

That field is only editable when the lead carries no company name, +though. As soon as the lead has one — which is the usual case for leads +coming from a website form or an import — the native wizard leaves no +way to do it:

+
    +
  • Create a new customer creates a second company out of the lead’s +company name, even when that company already exists;
  • +
  • Link to an existing customer links the company, and the lead’s +contact is never created.
  • +
+

This module fills that gap. It adds a sub-question under Link to an +existing customer, shown when the lead has a contact name and the +selected customer is a company, to decide what to do with that contact:

+
    +
  • Create a new contact on the existing company
  • +
  • Link to an existing contact of that company
  • +
  • Do not add a contact (keep the native behaviour: link the company, +and leave the lead’s contact aside)
  • +
+

If a contact of the company already matches the lead’s email, it is +pre-selected, so that no duplicate is created. When the lead has no +contact name, or the selected customer is a person, the native behaviour +is kept.

+

Screenshot lead to opportunity wizard

+

Table of contents

+ +
+

Bug Tracker

+

Bugs are tracked on GitHub Issues. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us to smash it by providing a detailed and welcomed +feedback.

+

Do not contact contributors directly about support or help with technical issues.

+
+
+

Credits

+
+

Authors

+
    +
  • Akretion
  • +
+
+
+

Contributors

+ +
+
+

Maintainers

+

This module is maintained by the OCA.

+ +Odoo Community Association + +

OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use.

+

Current maintainer:

+

alexis-via

+

This module is part of the OCA/crm project on GitHub.

+

You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute.

+
+
+
+
+ + diff --git a/crm_lead_to_opportunity_contact/tests/__init__.py b/crm_lead_to_opportunity_contact/tests/__init__.py new file mode 100644 index 00000000000..50ae2cfec8c --- /dev/null +++ b/crm_lead_to_opportunity_contact/tests/__init__.py @@ -0,0 +1,4 @@ +# Copyright 2026 ForgeFlow S.L. +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from . import test_crm_lead_to_opportunity_contact diff --git a/crm_lead_to_opportunity_contact/tests/test_crm_lead_to_opportunity_contact.py b/crm_lead_to_opportunity_contact/tests/test_crm_lead_to_opportunity_contact.py new file mode 100644 index 00000000000..b1b9a7b7d36 --- /dev/null +++ b/crm_lead_to_opportunity_contact/tests/test_crm_lead_to_opportunity_contact.py @@ -0,0 +1,300 @@ +# Copyright 2023 Akretion France (http://www.akretion.com/) +# Copyright 2026 ForgeFlow S.L. +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo.exceptions import UserError +from odoo.tests import TransactionCase + + +class TestCrmLeadToOpportunityContact(TransactionCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.company = cls.env["res.partner"].create( + {"name": "Test Company", "is_company": True} + ) + cls.contact = cls.env["res.partner"].create( + { + "name": "Existing Peter", + "parent_id": cls.company.id, + "email": "peter@example.com", + } + ) + + def _make_lead(self, **kw): + return self.env["crm.lead"].create({"name": "Test Lead", "type": "lead", **kw}) + + def _wizard(self, lead, **vals): + return ( + self.env["crm.lead2opportunity.partner"] + .with_context( + active_model="crm.lead", active_id=lead.id, active_ids=lead.ids + ) + .create({"name": "convert", **vals}) + ) + + def _convert(self, lead, **vals): + self._wizard(lead, **vals).action_apply() + return lead + + # ------------------------------------------------------------------ create + def test_create_contact(self): + """A new contact is created under the company and assigned to the opp.""" + lead = self._make_lead(contact_name="John Doe", email_from="john@example.com") + self._convert( + lead, + action="exist", + partner_id=self.company.id, + contact_action="create_contact", + ) + partner = lead.partner_id + self.assertFalse(partner.is_company) + self.assertEqual(partner.parent_id, self.company) + self.assertEqual(partner.name, "John Doe") + + def test_create_contact_does_not_duplicate_the_company(self): + """A lead carrying a company name must not create a second company. + + This is what the native wizard cannot do: with a company name on the + lead it either creates a duplicate company, or drops the contact. + """ + lead = self._make_lead( + contact_name="John Doe", + email_from="john@example.com", + partner_name=self.company.name, + ) + companies = self.env["res.partner"].search_count([("is_company", "=", True)]) + self._convert( + lead, + action="exist", + partner_id=self.company.id, + contact_action="create_contact", + ) + self.assertEqual(lead.partner_id.parent_id, self.company) + self.assertEqual( + self.env["res.partner"].search_count([("is_company", "=", True)]), + companies, + ) + + # -------------------------------------------------------------------- link + def test_link_contact(self): + """The opportunity is assigned to the selected existing contact.""" + lead = self._make_lead(contact_name="Whatever", email_from="x@example.com") + self._convert( + lead, + action="exist", + partner_id=self.company.id, + contact_action="link_contact", + contact_partner_id=self.contact.id, + ) + self.assertEqual(lead.partner_id, self.contact) + + def test_link_contact_requires_a_contact(self): + lead = self._make_lead(contact_name="John", email_from="john@example.com") + with self.assertRaises(UserError): + self._convert( + lead, + action="exist", + partner_id=self.company.id, + contact_action="link_contact", + ) + + # --------------------------------------------------------------- no contact + def test_no_contact_keeps_the_company(self): + """'Do not add a contact' keeps the native behaviour: link the company.""" + lead = self._make_lead(contact_name="John", email_from="john@example.com") + count = self.env["res.partner"].search_count([]) + self._convert( + lead, + action="exist", + partner_id=self.company.id, + contact_action="no_contact", + ) + self.assertEqual(lead.partner_id, self.company) + self.assertEqual(self.env["res.partner"].search_count([]), count) + + def test_without_contact_name_keeps_the_company(self): + """Without a contact name the sub-question does not apply (native). + + The email carries a name on purpose: without the ``contact_name`` gate + the native ``_create_customer`` would parse it and create a contact. + """ + lead = self._make_lead(email_from="Jane Roe ") + count = self.env["res.partner"].search_count([]) + self._convert(lead, action="exist", partner_id=self.company.id) + self.assertEqual(lead.partner_id, self.company) + self.assertEqual(self.env["res.partner"].search_count([]), count) + + # ----------------------------------------------------------- smart default + def test_smart_default_matches_by_email(self): + """A contact of the company matching the lead email is pre-selected.""" + lead = self._make_lead(contact_name="Peter", email_from="peter@example.com") + wizard = self._wizard(lead, action="exist", partner_id=self.company.id) + self.assertEqual(wizard.contact_action, "link_contact") + self.assertEqual(wizard.contact_partner_id, self.contact) + + def test_smart_default_without_match(self): + lead = self._make_lead(contact_name="New", email_from="new@example.com") + wizard = self._wizard(lead, action="exist", partner_id=self.company.id) + self.assertEqual(wizard.contact_action, "create_contact") + self.assertFalse(wizard.contact_partner_id) + + # -------------------------------------------------------------------- merge + def test_merge_does_not_apply_the_contact_sub_question(self): + """In 'merge' mode the sub-question is not asked, so it must not apply.""" + lead = self._make_lead(contact_name="John", email_from="john@example.com") + duplicate = self._make_lead(contact_name="John", email_from="john@example.com") + count = self.env["res.partner"].search_count([]) + wizard = self._wizard( + lead, + name="merge", + action="exist", + partner_id=self.company.id, + contact_action="create_contact", + duplicated_lead_ids=[(6, 0, duplicate.ids)], + ) + wizard.action_apply() + # native behaviour: the company is linked, no contact is created + self.assertEqual(self.env["res.partner"].search_count([]), count) + self.assertEqual(wizard.lead_id.partner_id, self.company) + + # --------------------------------------------------------------------- mass + def test_mass_convert_does_not_apply_the_contact_sub_question(self): + """The mass wizard does not ask the sub-question, so it must not apply it.""" + leads = self.env["crm.lead"].create( + [ + { + "name": f"Mass {i}", + "type": "lead", + "contact_name": f"Mass Contact {i}", + "email_from": f"mass{i}@example.com", + } + for i in range(3) + ] + ) + count = self.env["res.partner"].search_count([]) + wizard = ( + self.env["crm.lead2opportunity.partner.mass"] + .with_context( + active_model="crm.lead", active_ids=leads.ids, active_id=leads[0].id + ) + .create({"action": "exist", "partner_id": self.company.id}) + ) + wizard.action_mass_convert() + # native behaviour: every lead is linked to the company, nothing created + self.assertEqual(self.env["res.partner"].search_count([]), count) + self.assertEqual(leads.mapped("partner_id"), self.company) + + # -------------------------------------------------------------- robustness + def test_without_a_customer_falls_back_to_native(self): + """action='exist' without a customer must not crash (native fallback).""" + lead = self._make_lead(contact_name="John", email_from="john@example.com") + count = self.env["res.partner"].search_count([]) + wizard = self._wizard( + lead, action="exist", contact_action="create_contact", partner_id=False + ) + wizard.action_apply() + self.assertEqual(self.env["res.partner"].search_count([]), count) + + def test_a_person_customer_falls_back_to_native(self): + """A contact cannot be added to a person: keep the native behaviour.""" + lead = self._make_lead(contact_name="John", email_from="john@example.com") + count = self.env["res.partner"].search_count([]) + self._convert( + lead, + action="exist", + partner_id=self.contact.id, # a person, not a company + contact_action="create_contact", + ) + self.assertEqual(lead.partner_id, self.contact) + self.assertEqual(self.env["res.partner"].search_count([]), count) + + def test_link_contact_of_another_company_is_rejected(self): + """The view domain is enforced server side too.""" + other_company = self.env["res.partner"].create( + {"name": "Other Company", "is_company": True} + ) + alien = self.env["res.partner"].create( + {"name": "Alien", "parent_id": other_company.id} + ) + lead = self._make_lead(contact_name="John", email_from="john@example.com") + with self.assertRaises(UserError): + self._convert( + lead, + action="exist", + partner_id=self.company.id, + contact_action="link_contact", + contact_partner_id=alien.id, + ) + + def test_link_an_archived_contact_is_rejected(self): + """The view never offers archived contacts: enforce it server side.""" + self.contact.action_archive() + lead = self._make_lead(contact_name="John", email_from="john@example.com") + with self.assertRaises(UserError): + self._convert( + lead, + action="exist", + partner_id=self.company.id, + contact_action="link_contact", + contact_partner_id=self.contact.id, + ) + + def test_created_contact_gets_the_wizard_salesperson(self): + """The salesperson chosen in the wizard wins over the lead's one.""" + lead_user = self.env["res.users"].create( + {"name": "Lead Salesperson", "login": "l2o_contact_lead_user"} + ) + wizard_user = self.env.user + lead = self._make_lead( + contact_name="John", email_from="john@example.com", user_id=lead_user.id + ) + self._convert( + lead, + action="exist", + partner_id=self.company.id, + contact_action="create_contact", + user_id=wizard_user.id, + ) + self.assertEqual(lead.partner_id.user_id, wizard_user) + + def test_link_a_company_as_contact_is_rejected(self): + """The contact must be a person, never a company.""" + sub_company = self.env["res.partner"].create( + {"name": "Sub Company", "parent_id": self.company.id, "is_company": True} + ) + lead = self._make_lead(contact_name="John", email_from="john@example.com") + with self.assertRaises(UserError): + self._convert( + lead, + action="exist", + partner_id=self.company.id, + contact_action="link_contact", + contact_partner_id=sub_company.id, + ) + + # ----------------------------------------------------------- smart default + def test_smart_default_picks_the_oldest_contact(self): + """Like Odoo does, the oldest matching partner wins over the others.""" + oldest = self.env["res.partner"].create( + {"name": "Zoe", "parent_id": self.company.id, "email": "dup@example.com"} + ) + self.env["res.partner"].create( + {"name": "Ana", "parent_id": self.company.id, "email": "dup@example.com"} + ) + lead = self._make_lead(contact_name="Dup", email_from="dup@example.com") + wizard = self._wizard(lead, action="exist", partner_id=self.company.id) + # "Ana" comes first alphabetically, which is res.partner's default order + self.assertEqual(wizard.contact_partner_id, oldest) + + # -------------------------------------------------------------- native path + def test_native_create_customer_still_works(self): + lead = self._make_lead( + contact_name="Bob", partner_name="NewCo", email_from="bob@newco.com" + ) + self._convert(lead, action="create") + partner = lead.partner_id + self.assertEqual(partner.name, "Bob") + self.assertFalse(partner.is_company) + self.assertEqual(partner.parent_id.name, "NewCo") + self.assertTrue(partner.parent_id.is_company) diff --git a/crm_lead_to_opportunity_contact/wizards/__init__.py b/crm_lead_to_opportunity_contact/wizards/__init__.py new file mode 100644 index 00000000000..f34b21a0e8b --- /dev/null +++ b/crm_lead_to_opportunity_contact/wizards/__init__.py @@ -0,0 +1 @@ +from . import crm_lead_to_opportunity diff --git a/crm_lead_to_opportunity_contact/wizards/crm_lead_to_opportunity.py b/crm_lead_to_opportunity_contact/wizards/crm_lead_to_opportunity.py new file mode 100644 index 00000000000..c3ccd2859c7 --- /dev/null +++ b/crm_lead_to_opportunity_contact/wizards/crm_lead_to_opportunity.py @@ -0,0 +1,113 @@ +# Copyright 2023 Akretion France (http://www.akretion.com/) +# @author: Alexis de Lattre +# Copyright 2026 ForgeFlow S.L. +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo import api, fields, models +from odoo.exceptions import UserError + + +class CrmLead2OpportunityPartner(models.TransientModel): + _inherit = "crm.lead2opportunity.partner" + + # When linking to an existing company, this sub-question decides what + # happens to the lead's contact. + contact_action = fields.Selection( + selection=[ + ("create_contact", "Create a new contact"), + ("link_contact", "Link to an existing contact"), + ("no_contact", "Do not add a contact"), + ], + compute="_compute_contact", + store=True, + readonly=False, + ) + contact_partner_id = fields.Many2one( + "res.partner", + string="Contact", + compute="_compute_contact", + store=True, + readonly=False, + ) + # Only a company can be given a contact: when the selected customer is a + # person, the sub-question makes no sense and is not asked. + partner_is_company = fields.Boolean(related="partner_id.is_company") + + @api.depends("action", "lead_id", "partner_id.is_company") + def _compute_contact(self): + """Default the contact sub-question: if a contact of the selected + company already matches the lead (by email), pre-select it and default + to 'link'; otherwise default to 'create'.""" + for wiz in self: + match = wiz.env["res.partner"] + if wiz.action == "exist" and wiz.partner_is_company and wiz.lead_id: + match = wiz.lead_id._find_contact_in_company(wiz.partner_id) + wiz.contact_partner_id = match + wiz.contact_action = "link_contact" if match else "create_contact" + + def _contact_action_applies(self, lead): + """Whether the contact sub-question was actually asked to the user. + + It is only asked when converting (the "merge" mode hides the customer + question altogether), onto a selected existing customer that is a + company, and for a lead that has a contact name. In every other case the + native behaviour is kept: link the selected customer only. + """ + self.ensure_one() + return bool( + self.name == "convert" and self.partner_is_company and lead.contact_name + ) + + def _check_contact_partner(self): + """Validate the selected contact server side. + + The view already restricts ``contact_partner_id`` to the active contacts + of the selected company, but that domain is not enforced on other write + paths (RPC, data imports), so the same rules are checked here. + """ + self.ensure_one() + contact = self.contact_partner_id + if not contact: + raise UserError(self.env._("You must select a Contact.")) + if contact.is_company or contact.parent_id != self.partner_id: + raise UserError( + self.env._( + "The contact %(contact)s is not a contact of %(customer)s.", + contact=contact.display_name, + customer=self.partner_id.display_name, + ) + ) + if not contact.active: + raise UserError( + self.env._( + "The contact %(contact)s is archived.", + contact=contact.display_name, + ) + ) + + def _convert_handle_partner(self, lead, action, partner_id): + if action == "exist" and self._contact_action_applies(lead): + if self.contact_action == "create_contact": + # ``_create_customer`` attaches the new contact to ``with_parent`` + # instead of creating a company out of the lead's company name, + # and propagates the salesperson through ``default_user_id``, + # exactly like the native conversion does. + partner = lead.with_context( + default_user_id=self.user_id.id + )._create_customer(with_parent=self.partner_id) + lead.write({"partner_id": partner.id}) + return + if self.contact_action == "link_contact": + self._check_contact_partner() + lead.write({"partner_id": self.contact_partner_id.id}) + return + return super()._convert_handle_partner(lead, action, partner_id) + + +class CrmLead2OpportunityPartnerMass(models.TransientModel): + _inherit = "crm.lead2opportunity.partner.mass" + + def _contact_action_applies(self, lead): + """The mass conversion wizard never asks the contact sub-question, so it + must never apply it: every lead keeps the native behaviour.""" + return False diff --git a/crm_lead_to_opportunity_contact/wizards/crm_lead_to_opportunity_view.xml b/crm_lead_to_opportunity_contact/wizards/crm_lead_to_opportunity_view.xml new file mode 100644 index 00000000000..2c30f102791 --- /dev/null +++ b/crm_lead_to_opportunity_contact/wizards/crm_lead_to_opportunity_view.xml @@ -0,0 +1,58 @@ + + + + + crm.lead2opportunity.partner + + + + +
+
+ +
+
+
+ + +
+
+ + +
+
+
+
+ + + +