diff --git a/crm_lead_partner_by_vat/README.rst b/crm_lead_partner_by_vat/README.rst new file mode 100644 index 00000000000..13273741c61 --- /dev/null +++ b/crm_lead_partner_by_vat/README.rst @@ -0,0 +1,104 @@ +.. image:: https://odoo-community.org/readme-banner-image + :target: https://odoo-community.org/get-involved?utm_source=readme + :alt: Odoo Community Association + +======================= +CRM Lead Partner by VAT +======================= + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:6962ff5b931be23525d7dc8d35808a3ba465415ab2025074e561ae37677eea5a + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |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_partner_by_vat + :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_partner_by_vat + :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 a lead is matched to a partner, Odoo looks the partner up by email. +This module makes it look up a **company by VAT (TIN)** first, and only +falls back to the native email lookup when the lead has no VAT or no +company carries it. + +It builds on ``crm_lead_vat``, which adds the VAT field to leads. + +**Table of contents** + +.. contents:: + :local: + +Usage +===== + +1. Go to *CRM > Leads* and open a lead that has a TIN set. +2. Press *Convert to Opportunity*. +3. If a company has that TIN, it is proposed in the *Customer* field. + Otherwise the customer is looked up by email, as Odoo does natively. + +The proposal can always be changed: the *Customer* field stays editable +and any partner can be picked from it. + +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 +------- + +* ForgeFlow + +Contributors +------------ + +- Marc Gimeno + +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-MarcGForgeFlow| image:: https://github.com/MarcGForgeFlow.png?size=40px + :target: https://github.com/MarcGForgeFlow + :alt: MarcGForgeFlow + +Current `maintainer `__: + +|maintainer-MarcGForgeFlow| + +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_partner_by_vat/__init__.py b/crm_lead_partner_by_vat/__init__.py new file mode 100644 index 00000000000..4b76c7b2d5c --- /dev/null +++ b/crm_lead_partner_by_vat/__init__.py @@ -0,0 +1,3 @@ +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). + +from . import models diff --git a/crm_lead_partner_by_vat/__manifest__.py b/crm_lead_partner_by_vat/__manifest__.py new file mode 100644 index 00000000000..50fc7492ce4 --- /dev/null +++ b/crm_lead_partner_by_vat/__manifest__.py @@ -0,0 +1,16 @@ +# Copyright 2026 ForgeFlow S.L. +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). + +{ + "name": "CRM Lead Partner by VAT", + "summary": "Match the lead's partner by VAT (TIN) instead of email", + "version": "19.0.1.0.0", + "category": "Customer Relationship Management", + "website": "https://github.com/OCA/crm", + "author": "ForgeFlow, Odoo Community Association (OCA)", + "maintainers": ["MarcGForgeFlow"], + "license": "AGPL-3", + "application": False, + "installable": True, + "depends": ["crm_lead_vat"], +} diff --git a/crm_lead_partner_by_vat/models/__init__.py b/crm_lead_partner_by_vat/models/__init__.py new file mode 100644 index 00000000000..f11a18fc4e9 --- /dev/null +++ b/crm_lead_partner_by_vat/models/__init__.py @@ -0,0 +1,3 @@ +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). + +from . import crm_lead diff --git a/crm_lead_partner_by_vat/models/crm_lead.py b/crm_lead_partner_by_vat/models/crm_lead.py new file mode 100644 index 00000000000..2952f6edfa5 --- /dev/null +++ b/crm_lead_partner_by_vat/models/crm_lead.py @@ -0,0 +1,46 @@ +# Copyright 2026 ForgeFlow S.L. +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). + +from odoo import models + + +class CrmLead(models.Model): + _inherit = "crm.lead" + + def _vat_search_variants(self, vat): + """Return the VAT spellings to look for. + + ``res.partner.vat`` is free text: neither ``base`` nor ``base_vat`` + normalizes it on write, so it is stored exactly as it was typed. The + value as typed is therefore searched alongside the normalized one, and + the case is never altered, so that a partner whose VAT was stored + verbatim is found too. + """ + # Sometimes the VAT is typed with whitespaces or dots. + return (vat.replace(" ", "").replace(".", ""), vat) + + def _find_company_by_vat(self): + """Return the company matching the lead's VAT (TIN), if any. + + Exact matches are used on purpose (not ``=ilike``): the VAT is free + text, so wildcard characters must be compared literally, and equality + hits the index on ``res.partner.vat``. + """ + self.ensure_one() + if not self.vat: + return self.env["res.partner"] + return self.env["res.partner"].search( + [ + ("is_company", "=", True), + ("vat", "in", self._vat_search_variants(self.vat)), + ], + limit=1, + order="id", + ) + + def _find_matching_partner(self): + """Prefer the company matched by the lead's VAT, falling back to the + native email lookup when the lead has no VAT or no company carries it. + """ + self.ensure_one() + return self._find_company_by_vat() or super()._find_matching_partner() diff --git a/crm_lead_partner_by_vat/pyproject.toml b/crm_lead_partner_by_vat/pyproject.toml new file mode 100644 index 00000000000..4231d0cccb3 --- /dev/null +++ b/crm_lead_partner_by_vat/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["whool"] +build-backend = "whool.buildapi" diff --git a/crm_lead_partner_by_vat/readme/CONTRIBUTORS.md b/crm_lead_partner_by_vat/readme/CONTRIBUTORS.md new file mode 100644 index 00000000000..c03f4e70db6 --- /dev/null +++ b/crm_lead_partner_by_vat/readme/CONTRIBUTORS.md @@ -0,0 +1 @@ +- Marc Gimeno \<\> diff --git a/crm_lead_partner_by_vat/readme/DESCRIPTION.md b/crm_lead_partner_by_vat/readme/DESCRIPTION.md new file mode 100644 index 00000000000..1da9bc7f267 --- /dev/null +++ b/crm_lead_partner_by_vat/readme/DESCRIPTION.md @@ -0,0 +1,5 @@ +When a lead is matched to a partner, Odoo looks the partner up by email. This +module makes it look up a **company by VAT (TIN)** first, and only falls back +to the native email lookup when the lead has no VAT or no company carries it. + +It builds on `crm_lead_vat`, which adds the VAT field to leads. diff --git a/crm_lead_partner_by_vat/readme/USAGE.md b/crm_lead_partner_by_vat/readme/USAGE.md new file mode 100644 index 00000000000..e830867c26a --- /dev/null +++ b/crm_lead_partner_by_vat/readme/USAGE.md @@ -0,0 +1,7 @@ +1. Go to *CRM \> Leads* and open a lead that has a TIN set. +2. Press *Convert to Opportunity*. +3. If a company has that TIN, it is proposed in the *Customer* field. + Otherwise the customer is looked up by email, as Odoo does natively. + +The proposal can always be changed: the *Customer* field stays editable and +any partner can be picked from it. diff --git a/crm_lead_partner_by_vat/static/description/icon.png b/crm_lead_partner_by_vat/static/description/icon.png new file mode 100644 index 00000000000..3a0328b516c Binary files /dev/null and b/crm_lead_partner_by_vat/static/description/icon.png differ diff --git a/crm_lead_partner_by_vat/static/description/index.html b/crm_lead_partner_by_vat/static/description/index.html new file mode 100644 index 00000000000..8afbc5aff14 --- /dev/null +++ b/crm_lead_partner_by_vat/static/description/index.html @@ -0,0 +1,447 @@ + + + + + +README.rst + + + +
+ + + +Odoo Community Association + +
+

CRM Lead Partner by VAT

+ +

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

+

When a lead is matched to a partner, Odoo looks the partner up by email. +This module makes it look up a company by VAT (TIN) first, and only +falls back to the native email lookup when the lead has no VAT or no +company carries it.

+

It builds on crm_lead_vat, which adds the VAT field to leads.

+

Table of contents

+ +
+

Usage

+
    +
  1. Go to CRM > Leads and open a lead that has a TIN set.
  2. +
  3. Press Convert to Opportunity.
  4. +
  5. If a company has that TIN, it is proposed in the Customer field. +Otherwise the customer is looked up by email, as Odoo does natively.
  6. +
+

The proposal can always be changed: the Customer field stays editable +and any partner can be picked from it.

+
+
+

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

+
    +
  • ForgeFlow
  • +
+
+ +
+

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:

+

MarcGForgeFlow

+

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_partner_by_vat/tests/__init__.py b/crm_lead_partner_by_vat/tests/__init__.py new file mode 100644 index 00000000000..7f09ea0f0d0 --- /dev/null +++ b/crm_lead_partner_by_vat/tests/__init__.py @@ -0,0 +1,3 @@ +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). + +from . import test_crm_lead_partner_by_vat diff --git a/crm_lead_partner_by_vat/tests/test_crm_lead_partner_by_vat.py b/crm_lead_partner_by_vat/tests/test_crm_lead_partner_by_vat.py new file mode 100644 index 00000000000..a03db58911b --- /dev/null +++ b/crm_lead_partner_by_vat/tests/test_crm_lead_partner_by_vat.py @@ -0,0 +1,74 @@ +# Copyright 2026 ForgeFlow S.L. +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). + +from odoo.tests import TransactionCase, tagged + + +# Partners are created here, so every module extending res.partner must be +# loaded already: at install time the registry only knows this module's +# dependencies, while the database columns of the other ones are there. +@tagged("post_install", "-at_install") +class TestCrmLeadPartnerByVat(TransactionCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + # ``no_vat_validation`` lets us store arbitrary VATs without the + # base_vat format/country checks getting in the way. + cls.partner = cls.env["res.partner"].with_context(no_vat_validation=True) + cls.company = cls.partner.create( + {"name": "Acme A/S", "is_company": True, "vat": "DK12345678"} + ) + + def _lead(self, **kw): + return self.env["crm.lead"].create({"name": "Test Lead", "type": "lead", **kw}) + + # -------------------------------------------------------------------- VAT + def test_matched_by_vat(self): + lead = self._lead(vat="DK12345678", email_from="whatever@acme.com") + self.assertEqual(lead._find_matching_partner(), self.company) + + def test_vat_ignores_spaces_and_dots(self): + for value in ("DK 123 456 78", "DK.123.456.78"): + lead = self._lead(vat=value) + self.assertEqual(lead._find_matching_partner(), self.company) + + def test_vat_stored_verbatim_is_matched(self): + """The VAT is free text, so a lower-case one must be found as typed.""" + lowercase = self.partner.create( + {"name": "Lower Co", "is_company": True, "vat": "dk99999999"} + ) + lead = self._lead(vat="dk99999999") + self.assertEqual(lead._find_company_by_vat(), lowercase) + + def test_vat_wildcards_never_match(self): + for junk in ("%", "DK%", "DK______"): + lead = self._lead(vat=junk) + self.assertFalse(lead._find_company_by_vat()) + + def test_person_with_the_vat_is_ignored(self): + self.partner.create( + {"name": "Sole Trader", "is_company": False, "vat": "DK55555555"} + ) + lead = self._lead(vat="DK55555555") + self.assertFalse(lead._find_company_by_vat()) + + def test_oldest_company_wins(self): + older = self.partner.create( + {"name": "Old Co", "is_company": True, "vat": "DK77777777"} + ) + self.partner.create({"name": "New Co", "is_company": True, "vat": "DK77777777"}) + lead = self._lead(vat="DK77777777") + self.assertEqual(lead._find_company_by_vat(), older) + + # ----------------------------------------------------------- email fallback + def test_falls_back_to_email_without_vat(self): + """No VAT on the lead: keep the native email lookup.""" + contact = self.partner.create({"name": "Jane", "email": "jane@example.com"}) + lead = self._lead(email_from="jane@example.com") + self.assertEqual(lead._find_matching_partner(), contact) + + def test_vat_takes_priority_over_email(self): + """When both match, the VAT company wins over the email partner.""" + self.partner.create({"name": "Emailed", "email": "dup@example.com"}) + lead = self._lead(vat="DK12345678", email_from="dup@example.com") + self.assertEqual(lead._find_matching_partner(), self.company)