Your CRM Is Only as Good as Your Territory Design
Most Odoo CRM deployments start with a single sales team called "Sales" and every rep pulling leads from the same pool. It works when you have three salespeople in one office. It collapses the moment you expand to a second region, open a European subsidiary, or hire a team that sells exclusively to enterprise accounts.
The symptoms show up fast. Reps in Toronto chase leads in Sydney. Two people call the same prospect on the same day. The VP of Sales can't see pipeline by region without exporting to a spreadsheet. Revenue targets exist in a slide deck but never connect to the CRM. The root cause is always the same: no territory structure.
Odoo 19's CRM module supports multi-team hierarchies, rule-based lead assignment, territory-aware pipelines, and per-team quotas—but none of it works out of the box. This guide walks you through the complete configuration: how to structure sales teams, map territories by geography/industry/account size, automate lead routing, set team quotas, control cross-team visibility, and handle multi-currency pricing per region.
Designing the Sales Team Hierarchy in Odoo 19
Before configuring anything in Settings, you need a team design that matches how your business actually sells. Odoo's crm.team model is flat by default—no parent-child relationships. But you can simulate hierarchy through naming conventions, team-specific domains, and dashboard grouping.
Common Multi-Region Team Structures
| Structure | Best For | Odoo Implementation |
|---|---|---|
| Geographic (NA / EMEA / APAC) | B2B with regional pricing, local compliance | One crm.team per region, country tags on leads |
| Industry vertical (Healthcare / Finance / Retail) | Complex sales requiring domain expertise | One crm.team per vertical, industry field on contacts |
| Account size (SMB / Mid-Market / Enterprise) | Different sales motions per segment | One crm.team per tier, revenue-based assignment rules |
| Hybrid (Region × Segment) | Large orgs with 50+ reps | Teams like "NA Enterprise," "EMEA SMB" with combined rules |
Creating Sales Teams with Territory Context
<odoo>
<!-- North America Sales Team -->
<record id="team_na" model="crm.team">
<field name="name">North America</field>
<field name="use_leads">True</field>
<field name="use_opportunities">True</field>
<field name="company_id" ref="base.main_company"/>
<field name="user_id" ref="base.user_admin"/>
<field name="alias_name">sales-na</field>
</record>
<!-- EMEA Sales Team -->
<record id="team_emea" model="crm.team">
<field name="name">EMEA</field>
<field name="use_leads">True</field>
<field name="use_opportunities">True</field>
<field name="company_id" ref="company_eu"/>
<field name="user_id" ref="user_emea_manager"/>
<field name="alias_name">sales-emea</field>
</record>
<!-- APAC Sales Team -->
<record id="team_apac" model="crm.team">
<field name="name">APAC</field>
<field name="use_leads">True</field>
<field name="use_opportunities">True</field>
<field name="company_id" ref="company_apac"/>
<field name="user_id" ref="user_apac_manager"/>
<field name="alias_name">sales-apac</field>
</record>
</odoo> Each team's alias_name creates a dedicated email address (e.g., sales-na@yourcompany.com). Inbound emails to that alias automatically create leads assigned to the correct team. This is the simplest form of territory routing—configure it from day one.
Mapping Territories by Geography, Industry, and Account Size
A territory isn't just a region on a map. In Odoo 19, a territory is a set of rules that determine which team owns a lead based on one or more attributes: country, state/province, industry sector, estimated revenue, or any custom field on the contact or lead.
Step 1: Add Territory Fields to Leads and Contacts
from odoo import api, fields, models
class CrmLead(models.Model):
_inherit = "crm.lead"
x_territory = fields.Selection(
selection=[
("na", "North America"),
("emea", "EMEA"),
("apac", "APAC"),
("latam", "LATAM"),
],
string="Territory",
compute="_compute_territory",
store=True,
help="Auto-assigned based on country. "
"Manual override allowed.",
)
x_account_tier = fields.Selection(
selection=[
("smb", "SMB (<$1M revenue)"),
("mid", "Mid-Market ($1M-$50M)"),
("enterprise", "Enterprise ($50M+)"),
],
string="Account Tier",
help="Segment based on estimated annual revenue.",
)
x_industry_vertical = fields.Many2one(
"res.partner.industry",
string="Industry Vertical",
related="partner_id.industry_id",
store=True,
)
TERRITORY_MAP = {
"na": ["US", "CA", "MX"],
"emea": ["GB", "DE", "FR", "NL", "BE", "ES",
"IT", "SE", "NO", "DK", "CH", "AE"],
"apac": ["AU", "NZ", "JP", "SG", "IN", "KR",
"TH", "MY", "PH", "ID"],
"latam": ["BR", "AR", "CL", "CO", "PE"],
}
@api.depends("country_id")
def _compute_territory(self):
# Build reverse lookup: country_code -> territory
code_map = {{}}
for territory, codes in self.TERRITORY_MAP.items():
for code in codes:
code_map[code] = territory
for lead in self:
lead.x_territory = code_map.get(
lead.country_id.code, False
)Step 2: Territory Assignment Rules via Odoo Data
Odoo 19's lead assignment engine (crm.team.rule) lets you define domain-based rules that automatically route leads to the correct team. Here's how to configure rules that combine geography with account tier:
<odoo>
<!-- NA: all leads from US, CA, MX -->
<record id="rule_territory_na" model="crm.team.rule">
<field name="team_id" ref="team_na"/>
<field name="name">North America Territory</field>
<field name="domain">
[("x_territory", "=", "na")]
</field>
</record>
<!-- EMEA: European + Middle East leads -->
<record id="rule_territory_emea" model="crm.team.rule">
<field name="team_id" ref="team_emea"/>
<field name="name">EMEA Territory</field>
<field name="domain">
[("x_territory", "=", "emea")]
</field>
</record>
<!-- APAC: Asia-Pacific leads -->
<record id="rule_territory_apac" model="crm.team.rule">
<field name="team_id" ref="team_apac"/>
<field name="name">APAC Territory</field>
<field name="domain">
[("x_territory", "=", "apac")]
</field>
</record>
<!-- Enterprise tier override: all Enterprise
leads go to Strategic Accounts team -->
<record id="rule_enterprise_override"
model="crm.team.rule">
<field name="team_id" ref="team_strategic"/>
<field name="name">Enterprise Override</field>
<field name="sequence">5</field>
<field name="domain">
[("x_account_tier", "=", "enterprise")]
</field>
</record>
</odoo> Rules are evaluated in sequence order (lowest first). The Enterprise override rule has sequence=5, so it fires before geographic rules (default sequence is 10). This means a $100M enterprise lead from Germany goes to Strategic Accounts, not EMEA. Design your sequence numbers intentionally—they are your priority hierarchy.
Automating Lead and Opportunity Assignment by Territory
Territory rules decide which team gets the lead. But within a team of 8 reps, who gets it? Odoo 19 provides two assignment modes: round-robin and weighted assignment. Both are configured per-team.
Enabling Auto-Assignment
Navigate to CRM → Configuration → Settings and enable Rule-Based Assignment. This activates a scheduled action (crm.team.assign.leads) that runs periodically to distribute unassigned leads.
# Enable lead assignment in CRM settings
env["ir.config_parameter"].sudo().set_param(
"crm.lead.auto.assignment", "True"
)
# Set assignment frequency (in minutes)
env["ir.config_parameter"].sudo().set_param(
"crm.lead.assignment.cron.interval", "30"
)
# Configure per-member capacity on the team
na_team = env.ref("my_module.team_na")
for member in na_team.crm_team_member_ids:
# Max 20 active leads per rep
member.assignment_max = 20
# Domain filter: only assign leads in
# this rep's sub-territory (e.g., West Coast)
member.assignment_domain = (
'[("state_id.code", "in", '
'["CA", "WA", "OR", "NV", "AZ"])]'
)Round-Robin vs. Weighted Assignment
| Mode | How It Works | Best For |
|---|---|---|
| Round-Robin | Leads distributed evenly across team members in order | Equal territories, similar rep experience levels |
| Weighted | Each member has a capacity weight; higher weight = more leads | Senior reps handle more volume, junior reps ramp up |
Server Action for Real-Time Assignment
The default cron job runs every 30 minutes. For high-velocity sales teams, that's too slow. Here's a server action that triggers assignment immediately when a lead is created or its territory changes:
class CrmLead(models.Model):
_inherit = "crm.lead"
@api.model_create_multi
def create(self, vals_list):
leads = super().create(vals_list)
# Trigger immediate assignment for new leads
leads._handle_salesmen_assignment(
auto_commit=False
)
return leads
def write(self, vals):
res = super().write(vals)
if "x_territory" in vals or "country_id" in vals:
# Re-assign when territory changes
unassigned = self.filtered(
lambda l: not l.user_id
)
if unassigned:
unassigned._handle_salesmen_assignment(
auto_commit=False
)
return res The assignment_max field on crm.team.member is not a suggestion—it's a hard cap. Once a rep hits their max active leads, the assignment engine skips them. If all reps are at capacity, leads queue up unassigned. Monitor the "Unassigned Leads" dashboard weekly—a growing queue means your team is under-staffed or over-targeted.
Setting Team Quotas and Revenue Targets per Territory
Territory assignment is the plumbing. Quotas are the accountability. Odoo 19 provides crm.team invoicing targets out of the box, but most companies need more granularity: monthly targets, per-rep quotas, and pipeline coverage ratios.
Configuring Team-Level Targets
# Set invoicing target on the sales team
na_team = env.ref("my_module.team_na")
na_team.invoiced_target = 500000.00 # $500K/month
emea_team = env.ref("my_module.team_emea")
emea_team.invoiced_target = 350000.00 # EUR 350K/month
apac_team = env.ref("my_module.team_apac")
apac_team.invoiced_target = 200000.00 # AUD 200K/monthCustom Per-Rep Quota Tracking
Odoo's built-in target is team-level only. For per-rep quotas, extend the crm.team.member model:
from odoo import api, fields, models
class CrmTeamMember(models.Model):
_inherit = "crm.team.member"
x_monthly_quota = fields.Monetary(
string="Monthly Quota",
currency_field="x_currency_id",
help="Individual revenue target for this rep.",
)
x_currency_id = fields.Many2one(
"res.currency",
related="crm_team_id.company_id.currency_id",
)
x_quota_achieved = fields.Monetary(
string="Achieved (MTD)",
compute="_compute_quota_achieved",
currency_field="x_currency_id",
)
x_quota_pct = fields.Float(
string="Quota %",
compute="_compute_quota_achieved",
)
@api.depends("x_monthly_quota")
def _compute_quota_achieved(self):
today = fields.Date.today()
month_start = today.replace(day=1)
for member in self:
won_opps = self.env["crm.lead"].search([
("user_id", "=", member.user_id.id),
("team_id", "=", member.crm_team_id.id),
("stage_id.is_won", "=", True),
("date_closed", ">=", month_start),
("date_closed", "<=", today),
])
achieved = sum(won_opps.mapped(
"expected_revenue"
))
member.x_quota_achieved = achieved
member.x_quota_pct = (
(achieved / member.x_monthly_quota * 100)
if member.x_monthly_quota else 0.0
)A healthy sales team needs 3x pipeline coverage—$3 in active pipeline for every $1 of quota. If your NA team has a $500K monthly target, they need $1.5M in active opportunities. Build a dashboard widget that shows this ratio per team. When it drops below 2.5x, it's time to increase marketing spend or adjust targets.
Cross-Team Visibility Rules and Multi-Currency Per Region
In a multi-region setup, not every rep should see every deal. The EMEA team doesn't need to see North America's pipeline cluttering their Kanban view. But the VP of Sales needs to see everything. And each region prices in its local currency.
Record Rules for Team-Based Visibility
<odoo>
<!-- Sales reps see only their own team's leads -->
<record id="rule_lead_team_visibility"
model="ir.rule">
<field name="name">
CRM Lead: Team Visibility
</field>
<field name="model_id"
ref="crm.model_crm_lead"/>
<field name="domain_force">
['|',
('team_id', 'in',
user.sale_team_id.ids),
('team_id', '=', False)]
</field>
<field name="groups"
eval="[(4, ref('sales_team.group_sale_salesman'))]"/>
<field name="perm_read" eval="True"/>
<field name="perm_write" eval="True"/>
<field name="perm_create" eval="True"/>
<field name="perm_unlink" eval="False"/>
</record>
<!-- Sales managers see all teams they manage -->
<record id="rule_lead_manager_visibility"
model="ir.rule">
<field name="name">
CRM Lead: Manager Cross-Team
</field>
<field name="model_id"
ref="crm.model_crm_lead"/>
<field name="domain_force">[(1, '=', 1)]</field>
<field name="groups"
eval="[(4, ref('sales_team.group_sale_manager'))]"/>
</record>
</odoo>Multi-Currency Configuration Per Region
Each sales team is linked to a company via company_id. The company's currency determines how revenue is displayed on dashboards and reports. For multi-currency to work properly, you need:
- Currency rate updates — enable the ECB or Open Exchange Rates provider under Accounting → Settings → Currencies. Rates update daily.
- Pricelists per region — create a pricelist in EUR for EMEA, AUD for APAC, and USD for NA. Link them to the customer's country via fiscal position or pricelist rules.
- Pipeline amounts in team currency — opportunity
expected_revenueis stored in the company currency. When a Singapore rep logs a deal in SGD, it converts automatically using the day's rate.
# Assign pricelists based on lead territory
class CrmLead(models.Model):
_inherit = "crm.lead"
def _prepare_customer_values(self, partner_name,
is_company=False):
"""Override to assign regional pricelist
when converting lead to contact."""
values = super()._prepare_customer_values(
partner_name, is_company=is_company
)
pricelist_map = {{
"na": self.env.ref(
"my_module.pricelist_usd"),
"emea": self.env.ref(
"my_module.pricelist_eur"),
"apac": self.env.ref(
"my_module.pricelist_aud"),
}}
pricelist = pricelist_map.get(self.x_territory)
if pricelist:
values["property_product_pricelist"] = (
pricelist.id
)
return valuesThe CRM pipeline dashboard shows amounts in the current user's company currency, not the opportunity's currency. If your VP of Sales sits in the US company but views the EMEA pipeline, all amounts appear in USD at the current conversion rate. This is correct behavior—but confuse it with "wrong numbers" and you'll waste a week debugging. Add a note to your sales playbook explaining this.
3 Territory Configuration Mistakes That Sabotage Multi-Region Sales
Assigning Leads to Teams Without a Fallback Rule
You set up territory rules for NA, EMEA, and APAC. A lead comes in from Brazil. No rule matches. The lead sits unassigned with no team, invisible on every team's Kanban board. Nobody follows up. The prospect buys from your competitor three weeks later.
Always create a catch-all rule with the highest sequence number (lowest priority) and a domain of [(1, '=', 1)]. Assign it to a "Global / Unassigned" team that a sales ops person monitors daily. No lead should ever be teamless. Also set up an automated action that sends a Slack/email alert when leads land in the catch-all team.
Using Company-Level Multi-Company Rules When You Only Need Team Separation
You create separate Odoo companies for each region to "keep things clean." Now every cross-region report requires inter-company transactions, transfer pricing records, and a finance team that understands multi-company consolidation in Odoo. Your $50K CRM project just became a $200K ERP implementation because you used company separation where team separation would have sufficed.
Only use multi-company when you have separate legal entities that need independent chart of accounts, tax filings, and financial statements. For sales territory separation, use crm.team with record rules. Teams give you pipeline segmentation, assignment rules, and separate dashboards without the accounting complexity of multi-company.
Hardcoding Territory Logic in Python Instead of Using Configurable Rules
Your developer writes a create override with 50 lines of if/elif statements mapping countries to teams. It works. Then you expand to Latin America. The developer adds another elif. Then you split EMEA into "Western Europe" and "DACH." More elif statements. Six months later, every territory change requires a code deployment, a module upgrade, and a prayer that the logic doesn't conflict with the CRM assignment engine.
Use crm.team.rule records with domain expressions. These are data, not code. Sales ops can modify them through the UI (CRM → Configuration → Sales Teams → Assignment Rules) without developer involvement. Keep your Python limited to computed fields (like x_territory) that derive metadata. Let the assignment engine handle the routing logic.
What Proper Territory Configuration Delivers to Your Bottom Line
Territory design isn't a CRM admin task. It's a revenue architecture decision:
Leads routed to the right rep within minutes convert at significantly higher rates than leads that sit in a shared pool for hours. Speed-to-contact is the strongest predictor of conversion.
Automated assignment eliminates the "I was working that account" arguments. Clear territory boundaries mean reps focus on selling instead of defending turf.
When you hire a new rep or expand to a new region, reconfiguring rules takes 30 minutes instead of a sprint of developer work. Your sales ops team controls the map.
For a company with 200 inbound leads per month and a $25,000 average deal size, improving lead-to-opportunity conversion by just 5 percentage points (e.g., from 20% to 25%) adds $250,000 in pipeline per month. At a 30% close rate, that's $75,000 in additional monthly revenue—from a one-time CRM configuration project.
Optimization Metadata
Complete guide to configuring Odoo 19 sales teams and territories for multi-region operations. Covers territory mapping, automated lead assignment, team quotas, cross-team visibility, and multi-currency setup.
1. "Designing the Sales Team Hierarchy in Odoo 19"
2. "Mapping Territories by Geography, Industry, and Account Size"
3. "Automating Lead and Opportunity Assignment by Territory"
4. "Setting Team Quotas and Revenue Targets per Territory"
5. "3 Territory Configuration Mistakes That Sabotage Multi-Region Sales"