diff --git a/crm_team_zip_assign/README.rst b/crm_team_zip_assign/README.rst new file mode 100644 index 00000000000..d22983d86cf --- /dev/null +++ b/crm_team_zip_assign/README.rst @@ -0,0 +1,220 @@ +======================= +CRM Team ZIP Assignment +======================= + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:3cd3d416a5d908751e8e2a3d3342a40b61a84843c0d71e9f78477b67ca5a13ff + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |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/licence-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/16.0/crm_team_zip_assign + :alt: OCA/crm +.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png + :target: https://translation.odoo-community.org/projects/crm-16-0/crm-16-0-crm_team_zip_assign + :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=16.0 + :alt: Try me on Runboat + +|badge1| |badge2| |badge3| |badge4| |badge5| + +Auto-assign CRM teams to partners based on ZIP code patterns using regular expressions. + +.. image:: https://raw.githubusercontent.com/OCA/crm/16.0/crm_team_zip_assign/static/description/crm_team_form_view.png + +Features +-------- + +- Auto-assign CRM teams to partners based on ZIP code patterns +- Support for Python regular expressions with validation constraints +- Multi-company support +- Geographic filtering by countries and states +- Priority-based assignment when multiple teams match +- Exclusion flag for partners who should not be auto-assigned +- Real-time regex pattern validation prevents invalid patterns +- Contextual action for manual assignment from partner views +- Conditional assignment using pre-zip match conditions: teams can specify a domain expression (Odoo domain syntax) that must be satisfied by the partner before ZIP regex matching is performed. This allows for advanced filtering, e.g., only assign if the partner is a company or meets other criteria. + +Assignment Logic +~~~~~~~~~~~~~~~~ + +The assignment is triggered on partner create/write when ZIP, company, country, state, or exclusion flag changes. A contextual action is also available from partner views for manual assignment. The system: + +1. Finds all active teams with ZIP assignment enabled in the partner's company +2. Filters teams by matching countries and states (if specified) +3. For each eligible team, evaluates the optional pre-zip match condition (Odoo domain expression). If the partner does not satisfy the condition, the team is skipped. +4. Tests each remaining team's regex patterns against the partner's ZIP code +5. Selects the team with highest priority if multiple matches exist +6. Logs assignment activity for audit purposes + +Assignment Rules +~~~~~~~~~~~~~~~~ + +1. Only active teams with "Active ZIP Assignment" enabled are considered +2. Only teams in the same company as the partner are considered +3. Teams must have matching countries (partner's country must be in team's countries) +4. Teams must have matching states (partner's state must be in team's states, if team has states defined) +5. If a team has a pre-zip match condition, the partner must satisfy the condition (Odoo domain) before ZIP regex matching is performed. If not set, all partners are considered. +6. Partners without a company, ZIP code, country, or state are not assigned +7. Partners with "Exclude from ZIP Assignment" checked are not assigned +8. When multiple teams match, the team with highest priority is selected +9. Invalid regex patterns are prevented by validation constraints at input time + +**Table of contents** + +.. contents:: + :local: + +Usage +===== + +Setting up CRM Teams +--------------------- + +1. Go to CRM → Configuration → Sales Teams +2. Edit or create a CRM team +3. Enable "Active ZIP Assignment" checkbox +4. Set "ZIP Assignment Priority" (higher number = higher priority) +5. Configure geographic coverage: + - Select "Countries" where this team operates (required) + - Select "States" within those countries (required) +6. Add ZIP patterns in the "ZIP Patterns" tab +7. (Optional) Set a "Pre-Zip Match Condition" using Odoo domain syntax to restrict assignment to partners matching specific criteria (e.g., only companies, only certain types, etc.) + +Geographic Coverage +~~~~~~~~~~~~~~~~~~~ + +- **Countries**: Teams will only be considered for partners located in the selected countries +- **States**: Teams will only be considered for partners in the selected states +- **Domain Filtering**: State selection is automatically filtered based on selected countries + +Pre-Zip Match Condition +~~~~~~~~~~~~~~~~~~~~~~~ + +You can further restrict team assignment by specifying a domain condition in the "Pre-Zip Match Condition" field. This uses Odoo's domain syntax (e.g., `[('is_company', '=', True)]`). Only partners matching this condition will be considered for ZIP pattern matching for this team. + +Pre-Zip Match Condition Examples +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Some example domain conditions: + +- `[('is_company', '=', True)]` — Only assign to partners that are companies +- `[('type', '=', 'contact')]` — Only assign to contacts +- `[('industry_id', '!=', False)]` — Only assign to partners with an industry set + +You can combine multiple conditions, e.g. `[('is_company', '=', True), ('country_id', '=', ref('base.us'))]` + +ZIP Pattern Examples +~~~~~~~~~~~~~~~~~~~~ + +All patterns are validated in real-time to ensure they are valid Python regular expressions: + +- ``^1[0-5].*`` - ZIP codes starting with 10-15 +- ``^2[0-9].*`` - ZIP codes starting with 20-29 +- ``^751.*`` - ZIP codes starting with 751 +- ``.*123$`` - ZIP codes ending with 123 +- ``^[1-3].*`` - ZIP codes starting with 1, 2, or 3 +- ``^(10|20|30).*`` - ZIP codes starting with 10, 20, or 30 + +Pattern Validation +~~~~~~~~~~~~~~~~~~ + +The system validates regex patterns when they are entered: + +- Invalid patterns will show an error message immediately +- Error messages include the specific regex error for debugging +- Only valid patterns can be saved to the database + +Partner Configuration +--------------------- + +Partners have an "Exclude from ZIP Assignment" checkbox to prevent automatic assignment. + +Teams can have a "Pre-Zip Match Condition" to restrict assignment to partners matching specific criteria before ZIP pattern matching is performed. + +Usage +----- + +Automatic Assignment +~~~~~~~~~~~~~~~~~~~~ + + +Partners are automatically assigned to CRM teams when: + +- A partner is created with complete geographic information (ZIP, country, state) +- A partner's ZIP code is modified +- A partner's country or state is changed +- A partner's company is changed +- The exclusion flag is modified + +For each team, if a "Pre-Zip Match Condition" is set, the partner must match this condition before ZIP pattern matching is performed. If not set, all partners are considered for ZIP matching. + +Manual Assignment +~~~~~~~~~~~~~~~~~ + +You can also trigger assignment manually: + +- Use the contextual action available in partner views +- This is useful for reassigning existing partners after updating team configurations or after addon installation + +Assignment Requirements +~~~~~~~~~~~~~~~~~~~~~~~ + +For automatic assignment to work, partners must have: + +- A ZIP code +- A country +- A state +- A company +- "Exclude from ZIP Assignment" must be unchecked + +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 +~~~~~~~ + +* Binhex + +Contributors +~~~~~~~~~~~~ + +* Adasat Torres de León +* Rolando Pérez Rebollo + +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. + +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_team_zip_assign/__init__.py b/crm_team_zip_assign/__init__.py new file mode 100644 index 00000000000..efa66a975b9 --- /dev/null +++ b/crm_team_zip_assign/__init__.py @@ -0,0 +1,4 @@ +# Copyright 2025 Binhex +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from . import models diff --git a/crm_team_zip_assign/__manifest__.py b/crm_team_zip_assign/__manifest__.py new file mode 100644 index 00000000000..e78de16c4e2 --- /dev/null +++ b/crm_team_zip_assign/__manifest__.py @@ -0,0 +1,17 @@ +# Copyright 2025 Binhex +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +{ + "name": "CRM Team ZIP Assignment", + "summary": "Auto-assign CRM teams to partners based on ZIP code patterns", + "version": "16.0.1.0.0", + "license": "AGPL-3", + "author": "Binhex, Odoo Community Association (OCA)", + "website": "https://github.com/OCA/crm", + "depends": ["crm"], + "data": [ + "security/ir.model.access.csv", + "views/crm_team_views.xml", + "views/res_partner_views.xml", + ], +} diff --git a/crm_team_zip_assign/models/__init__.py b/crm_team_zip_assign/models/__init__.py new file mode 100644 index 00000000000..69694bf93d5 --- /dev/null +++ b/crm_team_zip_assign/models/__init__.py @@ -0,0 +1,6 @@ +# Copyright 2025 Binhex +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from . import crm_team +from . import crm_team_zip_pattern +from . import res_partner diff --git a/crm_team_zip_assign/models/crm_team.py b/crm_team_zip_assign/models/crm_team.py new file mode 100644 index 00000000000..5e2a8da913a --- /dev/null +++ b/crm_team_zip_assign/models/crm_team.py @@ -0,0 +1,75 @@ +# Copyright 2025 Binhex +# License AGPL-3.0 or later (http://www + +from odoo import Command, _, api, fields, models +from odoo.exceptions import ValidationError + + +class CrmTeam(models.Model): + _inherit = "crm.team" + + enable_zip_auto_assignment = fields.Boolean( + string="Enable Auto ZIP Assignment", + default=False, + help="If checked, this team will be considered for automatic assignment " + "based on ZIP code patterns.", + ) + + country_ids = fields.Many2many( + "res.country", + string="Countries", + help="Countries where this team operates", + default=lambda self: self.env.company.country_id.ids, + ) + state_ids = fields.Many2many( + "res.country.state", string="States", help="States where this team operates" + ) + + zip_regex_ids = fields.One2many( + comodel_name="crm.team.zip.pattern", + inverse_name="team_id", + string="ZIP Regex", + help="Regular expression patterns to match partner ZIP codes", + ) + + zip_assignment_priority = fields.Integer( + string="Assignment Priority", + default=0, + help="Higher priority teams will be preferred when multiple teams " + "match the same ZIP code. Higher number = higher priority.", + ) + + pre_zip_match_condition = fields.Char( + string="Pre-Zip Match Condition", + help=( + "If present, this condition must be satisfied before " + "matching the zip regular expression." + ), + ) + + @api.onchange("country_ids") + def _onchange_countries(self): + if self.country_ids: + domain = [("country_id", "in", self.country_ids.ids)] + self.state_ids = [Command.clear()] + else: + domain = [] + return {"domain": {"state_ids": domain}} + + @api.onchange("company_id") + def _onchange_company_id(self): + self.country_ids = [Command.clear()] + self.state_ids = [Command.clear()] + if self.company_id: + self.country_ids = [Command.set([self.company_id.country_id.id])] + + @api.constrains("enable_zip_auto_assignment", "company_id") + def _check_enable_zip_auto_assignment(self): + for team in self: + if team.enable_zip_auto_assignment and not team.company_id: + raise ValidationError( + _( + "A company must be set if " + "'Enable Auto ZIP Assignment' is checked." + ) + ) diff --git a/crm_team_zip_assign/models/crm_team_zip_pattern.py b/crm_team_zip_assign/models/crm_team_zip_pattern.py new file mode 100644 index 00000000000..2788f4caad4 --- /dev/null +++ b/crm_team_zip_assign/models/crm_team_zip_pattern.py @@ -0,0 +1,54 @@ +# Copyright 2025 Binhex +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +import re + +from odoo import _, api, fields, models +from odoo.exceptions import ValidationError + + +class CrmTeamZipPattern(models.Model): + _name = "crm.team.zip.pattern" + _description = "CRM Team ZIP Pattern" + _order = "team_id, id" + + name = fields.Char(compute="_compute_name", store=True) + + team_id = fields.Many2one( + comodel_name="crm.team", + string="CRM Team", + required=True, + ondelete="cascade", + ) + pattern = fields.Char( + string="ZIP Pattern", + required=True, + help="Python regular expression pattern to match ZIP codes. " + "Example: '^1[0-5].*' for ZIP codes starting with 10-15", + ) + + _sql_constraints = [ + ( + "unique_team_id_pattern_combination", + "UNIQUE(team_id, pattern)", + _("The pattern has already been assigned to this team."), + ) + ] + + @api.depends("team_id", "pattern") + def _compute_name(self): + for record in self: + name = f"{record.team_id.name} - {record.pattern}" + record.name = name + + @api.constrains("pattern") + def _check_pattern_validity(self): + """Validate that the pattern is a valid Python regex.""" + for record in self: + if record.pattern: + try: + re.compile(record.pattern) + except re.error as e: + raise ValidationError( + f"Invalid regex pattern '{record.pattern}': {str(e)}" + ) from e diff --git a/crm_team_zip_assign/models/res_partner.py b/crm_team_zip_assign/models/res_partner.py new file mode 100644 index 00000000000..3c7eba40a67 --- /dev/null +++ b/crm_team_zip_assign/models/res_partner.py @@ -0,0 +1,96 @@ +# Copyright 2025 Binhex +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +import ast +import logging +import re + +from odoo import api, fields, models +from odoo.tools import ustr + +_logger = logging.getLogger(__name__) + + +class ResPartner(models.Model): + _inherit = "res.partner" + + exclude_from_zip_assign = fields.Boolean( + string="Exclude from ZIP Assignment", + default=False, + help="If checked, this partner will not be automatically assigned " + "to a CRM team based on ZIP code patterns.", + ) + + @api.model_create_multi + def create(self, vals_list): + partners = super().create(vals_list) + partners._process_zip_assignment() + return partners + + def write(self, vals): + result = super().write(vals) + if "zip" in vals or "company_id" in vals or "exclude_from_zip_assign" in vals: + self._process_zip_assignment() + return result + + def _process_zip_assignment(self): + """Process ZIP assignment for multiple partners.""" + partners = self.filtered( + lambda partner: partner.zip + and partner.company_id + and not partner.exclude_from_zip_assign + and partner.country_id + and partner.state_id + and partner.type == "contact" + ) + if partners: + teams = self.env["crm.team"].search( + [ + ("enable_zip_auto_assignment", "=", True), + ("zip_regex_ids", "!=", False), + ("company_id", "!=", False), + ("country_ids", "!=", False), + ("state_ids", "!=", False), + ] + ) + for partner in partners: + selected_team = self._select_team_for_partner(partner, teams) + if selected_team: + _logger.info( + "Auto-assigning partner '%s' (ZIP: %s) to team '%s'", + partner.name, + partner.zip, + selected_team.name, + ) + partner.team_id = selected_team.id + return True + + def _select_team_for_partner(self, partner, teams): + """Select the best CRM team for a partner based on ZIP and location.""" + eligible_teams = teams.filtered( + lambda team: ( + team.company_id == partner.company_id + and team.country_ids & partner.country_id + and team.state_ids & partner.state_id + ) + ) + matching_teams = self.env["crm.team"] + for team in eligible_teams: + # Evaluate pre_zip_match_condition if present, else always match + if team.pre_zip_match_condition: + partner_domain = ast.literal_eval(ustr(team.pre_zip_match_condition)) + domain_match = bool(partner.filtered_domain(partner_domain)) + else: + domain_match = True + if domain_match: + if any( + re.match(pattern.pattern, partner.zip or "") + for pattern in team.zip_regex_ids + ): + matching_teams |= team + if matching_teams: + matching_teams = matching_teams.sorted( + key=lambda t: (-t.zip_assignment_priority, t.id) + ) + return matching_teams[:1] + return False diff --git a/crm_team_zip_assign/readme/CONTRIBUTORS.rst b/crm_team_zip_assign/readme/CONTRIBUTORS.rst new file mode 100644 index 00000000000..7bf434767cb --- /dev/null +++ b/crm_team_zip_assign/readme/CONTRIBUTORS.rst @@ -0,0 +1,2 @@ +* Adasat Torres de León +* Rolando Pérez Rebollo diff --git a/crm_team_zip_assign/readme/DESCRIPTION.rst b/crm_team_zip_assign/readme/DESCRIPTION.rst new file mode 100644 index 00000000000..091211d19ae --- /dev/null +++ b/crm_team_zip_assign/readme/DESCRIPTION.rst @@ -0,0 +1,41 @@ +Auto-assign CRM teams to partners based on ZIP code patterns using regular expressions. + +.. image:: ../static/description/crm_team_form_view.png + +Features +-------- + +- Auto-assign CRM teams to partners based on ZIP code patterns +- Support for Python regular expressions with validation constraints +- Multi-company support +- Geographic filtering by countries and states +- Priority-based assignment when multiple teams match +- Exclusion flag for partners who should not be auto-assigned +- Real-time regex pattern validation prevents invalid patterns +- Contextual action for manual assignment from partner views +- Conditional assignment using pre-zip match conditions: teams can specify a domain expression (Odoo domain syntax) that must be satisfied by the partner before ZIP regex matching is performed. This allows for advanced filtering, e.g., only assign if the partner is a company or meets other criteria. + +Assignment Logic +~~~~~~~~~~~~~~~~ + +The assignment is triggered on partner create/write when ZIP, company, country, state, or exclusion flag changes. A contextual action is also available from partner views for manual assignment. The system: + +1. Finds all active teams with ZIP assignment enabled in the partner's company +2. Filters teams by matching countries and states (if specified) +3. For each eligible team, evaluates the optional pre-zip match condition (Odoo domain expression). If the partner does not satisfy the condition, the team is skipped. +4. Tests each remaining team's regex patterns against the partner's ZIP code +5. Selects the team with highest priority if multiple matches exist +6. Logs assignment activity for audit purposes + +Assignment Rules +~~~~~~~~~~~~~~~~ + +1. Only active teams with "Active ZIP Assignment" enabled are considered +2. Only teams in the same company as the partner are considered +3. Teams must have matching countries (partner's country must be in team's countries) +4. Teams must have matching states (partner's state must be in team's states, if team has states defined) +5. If a team has a pre-zip match condition, the partner must satisfy the condition (Odoo domain) before ZIP regex matching is performed. If not set, all partners are considered. +6. Partners without a company, ZIP code, country, or state are not assigned +7. Partners with "Exclude from ZIP Assignment" checked are not assigned +8. When multiple teams match, the team with highest priority is selected +9. Invalid regex patterns are prevented by validation constraints at input time diff --git a/crm_team_zip_assign/readme/USAGE.rst b/crm_team_zip_assign/readme/USAGE.rst new file mode 100644 index 00000000000..68286690c29 --- /dev/null +++ b/crm_team_zip_assign/readme/USAGE.rst @@ -0,0 +1,99 @@ +Setting up CRM Teams +--------------------- + +1. Go to CRM → Configuration → Sales Teams +2. Edit or create a CRM team +3. Enable "Active ZIP Assignment" checkbox +4. Set "ZIP Assignment Priority" (higher number = higher priority) +5. Configure geographic coverage: + - Select "Countries" where this team operates (required) + - Select "States" within those countries (required) +6. Add ZIP patterns in the "ZIP Patterns" tab +7. (Optional) Set a "Pre-Zip Match Condition" using Odoo domain syntax to restrict assignment to partners matching specific criteria (e.g., only companies, only certain types, etc.) + +Geographic Coverage +~~~~~~~~~~~~~~~~~~~ + +- **Countries**: Teams will only be considered for partners located in the selected countries +- **States**: Teams will only be considered for partners in the selected states +- **Domain Filtering**: State selection is automatically filtered based on selected countries + +Pre-Zip Match Condition +~~~~~~~~~~~~~~~~~~~~~~~ + +You can further restrict team assignment by specifying a domain condition in the "Pre-Zip Match Condition" field. This uses Odoo's domain syntax (e.g., `[('is_company', '=', True)]`). Only partners matching this condition will be considered for ZIP pattern matching for this team. + +Pre-Zip Match Condition Examples +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Some example domain conditions: + +- `[('is_company', '=', True)]` — Only assign to partners that are companies +- `[('type', '=', 'contact')]` — Only assign to contacts +- `[('industry_id', '!=', False)]` — Only assign to partners with an industry set + +You can combine multiple conditions, e.g. `[('is_company', '=', True), ('country_id', '=', ref('base.us'))]` + +ZIP Pattern Examples +~~~~~~~~~~~~~~~~~~~~ + +All patterns are validated in real-time to ensure they are valid Python regular expressions: + +- ``^1[0-5].*`` - ZIP codes starting with 10-15 +- ``^2[0-9].*`` - ZIP codes starting with 20-29 +- ``^751.*`` - ZIP codes starting with 751 +- ``.*123$`` - ZIP codes ending with 123 +- ``^[1-3].*`` - ZIP codes starting with 1, 2, or 3 +- ``^(10|20|30).*`` - ZIP codes starting with 10, 20, or 30 + +Pattern Validation +~~~~~~~~~~~~~~~~~~ + +The system validates regex patterns when they are entered: + +- Invalid patterns will show an error message immediately +- Error messages include the specific regex error for debugging +- Only valid patterns can be saved to the database + +Partner Configuration +--------------------- + +Partners have an "Exclude from ZIP Assignment" checkbox to prevent automatic assignment. + +Teams can have a "Pre-Zip Match Condition" to restrict assignment to partners matching specific criteria before ZIP pattern matching is performed. + +Usage +----- + +Automatic Assignment +~~~~~~~~~~~~~~~~~~~~ + + +Partners are automatically assigned to CRM teams when: + +- A partner is created with complete geographic information (ZIP, country, state) +- A partner's ZIP code is modified +- A partner's country or state is changed +- A partner's company is changed +- The exclusion flag is modified + +For each team, if a "Pre-Zip Match Condition" is set, the partner must match this condition before ZIP pattern matching is performed. If not set, all partners are considered for ZIP matching. + +Manual Assignment +~~~~~~~~~~~~~~~~~ + +You can also trigger assignment manually: + +- Use the contextual action available in partner views +- This is useful for reassigning existing partners after updating team configurations or after addon installation + +Assignment Requirements +~~~~~~~~~~~~~~~~~~~~~~~ + +For automatic assignment to work, partners must have: + +- A ZIP code +- A country +- A state +- A company +- "Exclude from ZIP Assignment" must be unchecked diff --git a/crm_team_zip_assign/security/ir.model.access.csv b/crm_team_zip_assign/security/ir.model.access.csv new file mode 100644 index 00000000000..e680861458c --- /dev/null +++ b/crm_team_zip_assign/security/ir.model.access.csv @@ -0,0 +1,4 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_crm_team_zip_pattern_user,crm.team.zip.pattern.user,model_crm_team_zip_pattern,base.group_user,1,0,0,0 +access_crm_team_zip_pattern_manager,crm.team.zip.pattern.manager,model_crm_team_zip_pattern,sales_team.group_sale_manager,1,1,1,1 +access_crm_team_zip_pattern_salesman,crm.team.zip.pattern.salesman,model_crm_team_zip_pattern,sales_team.group_sale_salesman,1,1,1,0 diff --git a/crm_team_zip_assign/static/description/crm_team_form_view.png b/crm_team_zip_assign/static/description/crm_team_form_view.png new file mode 100644 index 00000000000..addf24708ab Binary files /dev/null and b/crm_team_zip_assign/static/description/crm_team_form_view.png differ diff --git a/crm_team_zip_assign/static/description/icon.png b/crm_team_zip_assign/static/description/icon.png new file mode 100644 index 00000000000..3a0328b516c Binary files /dev/null and b/crm_team_zip_assign/static/description/icon.png differ diff --git a/crm_team_zip_assign/static/description/index.html b/crm_team_zip_assign/static/description/index.html new file mode 100644 index 00000000000..7cd7dda2961 --- /dev/null +++ b/crm_team_zip_assign/static/description/index.html @@ -0,0 +1,558 @@ + + + + + +CRM Team ZIP Assignment + + + +
+

CRM Team ZIP Assignment

+ + +

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

+

Auto-assign CRM teams to partners based on ZIP code patterns using regular expressions.

+https://raw.githubusercontent.com/OCA/crm/16.0/crm_team_zip_assign/static/description/crm_team_form_view.png +
+

Features

+
    +
  • Auto-assign CRM teams to partners based on ZIP code patterns
  • +
  • Support for Python regular expressions with validation constraints
  • +
  • Multi-company support
  • +
  • Geographic filtering by countries and states
  • +
  • Priority-based assignment when multiple teams match
  • +
  • Exclusion flag for partners who should not be auto-assigned
  • +
  • Real-time regex pattern validation prevents invalid patterns
  • +
  • Contextual action for manual assignment from partner views
  • +
  • Conditional assignment using pre-zip match conditions: teams can specify a domain expression (Odoo domain syntax) that must be satisfied by the partner before ZIP regex matching is performed. This allows for advanced filtering, e.g., only assign if the partner is a company or meets other criteria.
  • +
+
+

Assignment Logic

+

The assignment is triggered on partner create/write when ZIP, company, country, state, or exclusion flag changes. A contextual action is also available from partner views for manual assignment. The system:

+
    +
  1. Finds all active teams with ZIP assignment enabled in the partner’s company
  2. +
  3. Filters teams by matching countries and states (if specified)
  4. +
  5. For each eligible team, evaluates the optional pre-zip match condition (Odoo domain expression). If the partner does not satisfy the condition, the team is skipped.
  6. +
  7. Tests each remaining team’s regex patterns against the partner’s ZIP code
  8. +
  9. Selects the team with highest priority if multiple matches exist
  10. +
  11. Logs assignment activity for audit purposes
  12. +
+
+
+

Assignment Rules

+
    +
  1. Only active teams with “Active ZIP Assignment” enabled are considered
  2. +
  3. Only teams in the same company as the partner are considered
  4. +
  5. Teams must have matching countries (partner’s country must be in team’s countries)
  6. +
  7. Teams must have matching states (partner’s state must be in team’s states, if team has states defined)
  8. +
  9. If a team has a pre-zip match condition, the partner must satisfy the condition (Odoo domain) before ZIP regex matching is performed. If not set, all partners are considered.
  10. +
  11. Partners without a company, ZIP code, country, or state are not assigned
  12. +
  13. Partners with “Exclude from ZIP Assignment” checked are not assigned
  14. +
  15. When multiple teams match, the team with highest priority is selected
  16. +
  17. Invalid regex patterns are prevented by validation constraints at input time
  18. +
+

Table of contents

+
+ +
+
+

Usage

+
+
+
+
+

Setting up CRM Teams

+
    +
  1. Go to CRM → Configuration → Sales Teams
  2. +
  3. Edit or create a CRM team
  4. +
  5. Enable “Active ZIP Assignment” checkbox
  6. +
  7. Set “ZIP Assignment Priority” (higher number = higher priority)
  8. +
  9. Configure geographic coverage: +- Select “Countries” where this team operates (required) +- Select “States” within those countries (required)
  10. +
  11. Add ZIP patterns in the “ZIP Patterns” tab
  12. +
  13. (Optional) Set a “Pre-Zip Match Condition” using Odoo domain syntax to restrict assignment to partners matching specific criteria (e.g., only companies, only certain types, etc.)
  14. +
+
+

Geographic Coverage

+
    +
  • Countries: Teams will only be considered for partners located in the selected countries
  • +
  • States: Teams will only be considered for partners in the selected states
  • +
  • Domain Filtering: State selection is automatically filtered based on selected countries
  • +
+
+
+

Pre-Zip Match Condition

+

You can further restrict team assignment by specifying a domain condition in the “Pre-Zip Match Condition” field. This uses Odoo’s domain syntax (e.g., [(‘is_company’, ‘=’, True)]). Only partners matching this condition will be considered for ZIP pattern matching for this team.

+
+
+

Pre-Zip Match Condition Examples

+

Some example domain conditions:

+
    +
  • [(‘is_company’, ‘=’, True)] — Only assign to partners that are companies
  • +
  • [(‘type’, ‘=’, ‘contact’)] — Only assign to contacts
  • +
  • [(‘industry_id’, ‘!=’, False)] — Only assign to partners with an industry set
  • +
+

You can combine multiple conditions, e.g. [(‘is_company’, ‘=’, True), (‘country_id’, ‘=’, ref(‘base.us’))]

+
+
+

ZIP Pattern Examples

+

All patterns are validated in real-time to ensure they are valid Python regular expressions:

+
    +
  • ^1[0-5].* - ZIP codes starting with 10-15
  • +
  • ^2[0-9].* - ZIP codes starting with 20-29
  • +
  • ^751.* - ZIP codes starting with 751
  • +
  • .*123$ - ZIP codes ending with 123
  • +
  • ^[1-3].* - ZIP codes starting with 1, 2, or 3
  • +
  • ^(10|20|30).* - ZIP codes starting with 10, 20, or 30
  • +
+
+
+

Pattern Validation

+

The system validates regex patterns when they are entered:

+
    +
  • Invalid patterns will show an error message immediately
  • +
  • Error messages include the specific regex error for debugging
  • +
  • Only valid patterns can be saved to the database
  • +
+
+
+
+

Partner Configuration

+

Partners have an “Exclude from ZIP Assignment” checkbox to prevent automatic assignment.

+

Teams can have a “Pre-Zip Match Condition” to restrict assignment to partners matching specific criteria before ZIP pattern matching is performed.

+
+
+

Usage

+
+

Automatic Assignment

+

Partners are automatically assigned to CRM teams when:

+
    +
  • A partner is created with complete geographic information (ZIP, country, state)
  • +
  • A partner’s ZIP code is modified
  • +
  • A partner’s country or state is changed
  • +
  • A partner’s company is changed
  • +
  • The exclusion flag is modified
  • +
+

For each team, if a “Pre-Zip Match Condition” is set, the partner must match this condition before ZIP pattern matching is performed. If not set, all partners are considered for ZIP matching.

+
+
+

Manual Assignment

+

You can also trigger assignment manually:

+
    +
  • Use the contextual action available in partner views
  • +
  • This is useful for reassigning existing partners after updating team configurations or after addon installation
  • +
+
+
+

Assignment Requirements

+

For automatic assignment to work, partners must have:

+
    +
  • A ZIP code
  • +
  • A country
  • +
  • A state
  • +
  • A company
  • +
  • “Exclude from ZIP Assignment” must be unchecked
  • +
+
+

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

+
    +
  • Binhex
  • +
+
+
+

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.

+

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_team_zip_assign/tests/__init__.py b/crm_team_zip_assign/tests/__init__.py new file mode 100644 index 00000000000..cdcddd53fd7 --- /dev/null +++ b/crm_team_zip_assign/tests/__init__.py @@ -0,0 +1,3 @@ +# Copyright 2025 Binhex +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). +from . import test_zip_assignment diff --git a/crm_team_zip_assign/tests/test_zip_assignment.py b/crm_team_zip_assign/tests/test_zip_assignment.py new file mode 100644 index 00000000000..eb2cc5fe997 --- /dev/null +++ b/crm_team_zip_assign/tests/test_zip_assignment.py @@ -0,0 +1,459 @@ +# Copyright 2025 Binhex +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from psycopg2.errors import UniqueViolation + +from odoo.exceptions import ValidationError + +from odoo.addons.base.tests.common import BaseCommon + + +class TestZipAssignment(BaseCommon): + @classmethod + def setUpClass(cls): + super().setUpClass() + + # Create countries and states + + cls.state_ny = cls.env.ref("base.state_us_27") # New York + cls.state_ca = cls.env.ref("base.state_us_5") # California + cls.country_us = cls.state_ny.country_id # USA + cls.state_on = cls.env.ref("base.state_ca_on") # Ontario + cls.country_ca = cls.state_on.country_id # Canada + + # Create companies + cls.company_a = cls.env["res.company"].create( + { + "name": "Company A", + "country_id": cls.country_us.id, + } + ) + cls.company_b = cls.env["res.company"].create( + { + "name": "Company B", + "country_id": cls.country_us.id, + } + ) + + # Create CRM teams + cls.team_north = cls.env["crm.team"].create( + { + "name": "North Team", + "company_id": cls.company_a.id, + "enable_zip_auto_assignment": True, + "zip_assignment_priority": 10, # Higher priority + "country_ids": [(6, 0, [cls.country_us.id])], + "state_ids": [(6, 0, [cls.state_ny.id])], + } + ) + + cls.team_north_lesser_priority = cls.env["crm.team"].create( + { + "name": "North Team Lesser Priority", + "company_id": cls.company_a.id, + "enable_zip_auto_assignment": True, + "zip_assignment_priority": 4, # Lower priority + "country_ids": [(6, 0, [cls.country_us.id])], + "state_ids": [(6, 0, [cls.state_ny.id])], + } + ) + + cls.team_south = cls.env["crm.team"].create( + { + "name": "South Team", + "company_id": cls.company_a.id, + "enable_zip_auto_assignment": True, + "zip_assignment_priority": 5, + "country_ids": [(6, 0, [cls.country_us.id])], + "state_ids": [(6, 0, [cls.state_ca.id])], + } + ) + + cls.team_company_b = cls.env["crm.team"].create( + { + "name": "Company B Team", + "company_id": cls.company_b.id, + "enable_zip_auto_assignment": True, + "zip_assignment_priority": 1, + "country_ids": [(6, 0, [cls.country_us.id])], + "state_ids": [(6, 0, [cls.state_ny.id])], + } + ) + + cls.team_inactive = cls.env["crm.team"].create( + { + "name": "Inactive Team", + "company_id": cls.company_a.id, + "enable_zip_auto_assignment": False, + "zip_assignment_priority": 20, + "country_ids": [(6, 0, [cls.country_us.id])], + "state_ids": [(6, 0, [cls.state_ny.id])], + } + ) + + cls.team_company = cls.env["crm.team"].create( + { + "name": "Team Company", + "company_id": cls.company_a.id, + "enable_zip_auto_assignment": True, + "zip_assignment_priority": 10, + "country_ids": [(6, 0, [cls.country_us.id])], + "state_ids": [(6, 0, [cls.state_ny.id])], + "pre_zip_match_condition": "[('is_company', '=', True)]", + } + ) + + cls.team_person = cls.env["crm.team"].create( + { + "name": "Team Person", + "company_id": cls.company_a.id, + "enable_zip_auto_assignment": True, + "zip_assignment_priority": 10, + "country_ids": [(6, 0, [cls.country_us.id])], + "state_ids": [(6, 0, [cls.state_ny.id])], + "pre_zip_match_condition": "[('is_company', '=', False)]", + } + ) + + # Create ZIP patterns + cls.env["crm.team.zip.pattern"].create( + [ + { + "team_id": cls.team_north.id, + "pattern": r"^1[0-5].*", # ZIP starting with 10-15 + }, + { + "team_id": cls.team_north_lesser_priority.id, + "pattern": r"^1[0-5].*", # Same pattern to test priority + }, + { + "team_id": cls.team_south.id, + "pattern": r"^2[0-9].*", # ZIP starting with 20-29 + }, + { + "team_id": cls.team_south.id, + "pattern": r"^1[6-9].*", # ZIP starting with 16-19 + }, + { + "team_id": cls.team_company_b.id, + "pattern": r"^1[0-5].*", # Same pattern as north team + }, + { + "team_id": cls.team_inactive.id, + "pattern": r"^3[0-9].*", # ZIP starting with 30-39 + }, + { + "team_id": cls.team_company.id, + "pattern": r"^9[6-8].*", # ZIP starting with 96-98 + }, + { + "team_id": cls.team_person.id, + "pattern": r"^9[6-8].*", # ZIP starting with 96-98 + }, + ] + ) + + def test_single_match(self): + """Test partner assignment with single team match.""" + partner = self.env["res.partner"].create( + { + "name": "Test Partner North", + "zip": "12345", + "company_id": self.company_a.id, + "country_id": self.country_us.id, + "state_id": self.state_ny.id, + "is_company": False, + } + ) + self.assertEqual(partner.team_id, self.team_north) + + def test_multiple_matches_priority(self): + """Test priority when multiple teams match.""" + # Both teams have patterns for ZIP starting with 10-15 + # North team has higher priority (10 vs 4) + partner = self.env["res.partner"].create( + { + "name": "Test Partner Priority", + "zip": "15789", + "company_id": self.company_a.id, + "country_id": self.country_us.id, + "state_id": self.state_ny.id, + "is_company": False, + } + ) + self.assertEqual(partner.team_id, self.team_north) + + def test_no_match(self): + """Test no assignment when no team matches.""" + partner = self.env["res.partner"].create( + { + "name": "Test Partner No Match", + "zip": "99999", + "company_id": self.company_a.id, + "country_id": self.country_us.id, + "state_id": self.state_ny.id, + "is_company": False, + } + ) + self.assertFalse(partner.team_id) + + def test_exclusion_flag(self): + """Test that excluded partners are not assigned.""" + partner = self.env["res.partner"].create( + { + "name": "Test Partner Excluded", + "zip": "12345", + "company_id": self.company_a.id, + "country_id": self.country_us.id, + "state_id": self.state_ny.id, + "exclude_from_zip_assign": True, + "is_company": False, + } + ) + self.assertFalse(partner.team_id) + + def test_multi_company(self): + """Test multi-company isolation.""" + # Partner in company A should get team_north + partner_a = self.env["res.partner"].create( + { + "name": "Test Partner Company A", + "zip": "12345", + "company_id": self.company_a.id, + "country_id": self.country_us.id, + "state_id": self.state_ny.id, + "is_company": False, + } + ) + self.assertEqual(partner_a.team_id, self.team_north) + + # Partner in company B should get team_company_b + partner_b = self.env["res.partner"].create( + { + "name": "Test Partner Company B", + "zip": "12345", + "company_id": self.company_b.id, + "country_id": self.country_us.id, + "state_id": self.state_ny.id, + "is_company": False, + } + ) + self.assertEqual(partner_b.team_id, self.team_company_b) + + def test_inactive_team_ignored(self): + """Test that inactive teams are ignored.""" + partner = self.env["res.partner"].create( + { + "name": "Test Partner Inactive", + "zip": "30123", + "company_id": self.company_a.id, + "country_id": self.country_us.id, + "state_id": self.state_ny.id, + "is_company": False, + } + ) + self.assertFalse(partner.team_id) + + def test_invalid_regex(self): + """Test that invalid regex patterns are prevented by constraints.""" + # Test constraint validation prevents invalid patterns + with self.assertRaises(ValidationError): + self.env["crm.team.zip.pattern"].create( + { + "team_id": self.team_north.id, + "pattern": r"[invalid(regex", # Invalid pattern + } + ) + + def test_repeated_pattern_in_team(self): + """Test unique team_id pattern combination in crm team.""" + with self.assertRaises(UniqueViolation): + self.env["crm.team.zip.pattern"].create( + { + "team_id": self.team_north.id, + "pattern": r"^1[0-5].*", # ZIP starting with 10-15 + } + ) + + def test_enable_zip_auto_assignment_requires_company(self): + """ + Test that enabling zip auto assignment without company + raises ValidationError. + """ + with self.assertRaises(ValidationError): + self.env["crm.team"].create( + { + "name": "No Company Team", + "enable_zip_auto_assignment": True, + "company_id": None, + } + ) + + def test_geographic_filtering(self): + """Test that geographic filtering works correctly.""" + # Partner in wrong state should not be assigned + partner_wrong_state = self.env["res.partner"].create( + { + "name": "Test Partner Wrong State", + "zip": "12345", + "company_id": self.company_a.id, + "country_id": self.country_us.id, + "state_id": self.state_ca.id, # California, but team_north is for NY + "is_company": False, + } + ) + self.assertFalse(partner_wrong_state.team_id) + + # Partner in wrong country should not be assigned + partner_wrong_country = self.env["res.partner"].create( + { + "name": "Test Partner Wrong Country", + "zip": "12345", + "company_id": self.company_a.id, + "country_id": self.country_ca.id, # Canada + "state_id": self.state_on.id, + "is_company": False, + } + ) + self.assertFalse(partner_wrong_country.team_id) + + def test_no_company_no_assignment(self): + """Test that partners without company are not assigned.""" + partner = self.env["res.partner"].create( + { + "name": "Test Partner No Company", + "zip": "12345", + "country_id": self.country_us.id, + "state_id": self.state_ny.id, + "is_company": False, + } + ) + self.assertFalse(partner.team_id) + + def test_no_zip_no_assignment(self): + """Test that partners without ZIP are not assigned.""" + partner = self.env["res.partner"].create( + { + "name": "Test Partner No ZIP", + "company_id": self.company_a.id, + "country_id": self.country_us.id, + "state_id": self.state_ny.id, + "is_company": False, + } + ) + self.assertFalse(partner.team_id) + + def test_no_country_no_assignment(self): + """Test that partners without country are not assigned.""" + partner = self.env["res.partner"].create( + { + "name": "Test Partner No Country", + "zip": "12345", + "company_id": self.company_a.id, + "country_id": False, + "is_company": False, + } + ) + self.assertFalse(partner.team_id) + + def test_no_state_no_assignment(self): + """Test that partners without state are not assigned.""" + partner = self.env["res.partner"].create( + { + "name": "Test Partner No State", + "zip": "12345", + "company_id": self.company_a.id, + "country_id": self.country_us.id, + "is_company": False, + } + ) + self.assertFalse(partner.team_id) + + def test_write_zip_triggers_assignment(self): + """Test that changing ZIP triggers reassignment.""" + partner = self.env["res.partner"].create( + { + "name": "Test Partner Write", + "zip": "99999", # No match + "company_id": self.company_a.id, + "country_id": self.country_us.id, + "state_id": self.state_ny.id, + "is_company": False, + } + ) + self.assertFalse(partner.team_id) + + # Change ZIP to matching pattern + partner.write({"zip": "12345"}) + self.assertEqual(partner.team_id, self.team_north) + + def test_write_company_triggers_assignment(self): + """Test that changing company triggers reassignment.""" + partner = self.env["res.partner"].create( + { + "name": "Test Partner Write Company", + "zip": "12345", + "company_id": self.company_b.id, + "country_id": self.country_us.id, + "state_id": self.state_ny.id, + "is_company": False, + } + ) + self.assertEqual(partner.team_id, self.team_company_b) + + # Change company + partner.write({"company_id": self.company_a.id}) + self.assertEqual(partner.team_id, self.team_north) + + def test_write_exclusion_flag(self): + """Test that changing exclusion flag works correctly.""" + partner = self.env["res.partner"].create( + { + "name": "Test Partner Exclusion", + "zip": "12345", + "company_id": self.company_a.id, + "country_id": self.country_us.id, + "state_id": self.state_ny.id, + "is_company": False, + } + ) + self.assertEqual(partner.team_id, self.team_north) + + # Exclude from assignment + partner.write({"exclude_from_zip_assign": True}) + # Team should remain (exclusion doesn't clear existing assignment) + self.assertEqual(partner.team_id, self.team_north) + + # Include again + partner.write({"exclude_from_zip_assign": False}) + # Should still be assigned to same team + self.assertEqual(partner.team_id, self.team_north) + + def test_pre_zip_match_condition(self): + """Test that pre_zip_match_condition filters partners as expected.""" + # Setup: two teams, same country/state/zip/priority, different pre_zip_match_condition + # Partner is a company + partner_company = self.env["res.partner"].create( + { + "name": "Partner Company", + "zip": "96765", + "company_id": self.company_a.id, + "country_id": self.country_us.id, + "state_id": self.state_ny.id, + "is_company": True, + } + ) + self.assertEqual(partner_company.team_id, self.team_company) + + # Partner is a person + partner_person = self.env["res.partner"].create( + { + "name": "Partner Person", + "zip": "96765", + "company_id": self.company_a.id, + "country_id": self.country_us.id, + "state_id": self.state_ny.id, + "is_company": False, + } + ) + self.assertEqual(partner_person.team_id, self.team_person) diff --git a/crm_team_zip_assign/views/crm_team_views.xml b/crm_team_zip_assign/views/crm_team_views.xml new file mode 100644 index 00000000000..2ff1083e758 --- /dev/null +++ b/crm_team_zip_assign/views/crm_team_views.xml @@ -0,0 +1,69 @@ + + + + + + crm.team.form.zip.assign + crm.team + + + + + + + + + + + + + + + + + + + + + + + + + + + crm.team.tree.zip.assign + crm.team + + + + + + + + + diff --git a/crm_team_zip_assign/views/res_partner_views.xml b/crm_team_zip_assign/views/res_partner_views.xml new file mode 100644 index 00000000000..5fceb52d56b --- /dev/null +++ b/crm_team_zip_assign/views/res_partner_views.xml @@ -0,0 +1,25 @@ + + + + + + res.partner.form.zip.assign + res.partner + + + + + + + + + + Assign CRM Team By Zip + + + form,list + code + records._process_zip_assignment() + + diff --git a/setup/crm_team_zip_assign/odoo/addons/crm_team_zip_assign b/setup/crm_team_zip_assign/odoo/addons/crm_team_zip_assign new file mode 120000 index 00000000000..442bb612e80 --- /dev/null +++ b/setup/crm_team_zip_assign/odoo/addons/crm_team_zip_assign @@ -0,0 +1 @@ +../../../../crm_team_zip_assign \ No newline at end of file diff --git a/setup/crm_team_zip_assign/setup.py b/setup/crm_team_zip_assign/setup.py new file mode 100644 index 00000000000..b908cbe55cb --- /dev/null +++ b/setup/crm_team_zip_assign/setup.py @@ -0,0 +1,3 @@ +import setuptools + +setuptools.setup()