Odoo Python Development: Complete Guide for Beginners and Pros
According to the 2025 Python Developer Survey, over 47% of enterprise Python developers work with at least one ERP framework, and Odoo dominates that segment with 68% market share among open-source ERPs. Whether you are building your first module or architecting a multi-company deployment, understanding Odoo's Python layer is the single most impactful skill you can develop.
This guide goes far beyond surface-level overviews. We will walk through every layer of Odoo's Python backend — from the ORM's metaclass magic to advanced controller patterns, from debugging production issues to writing tests that actually catch regressions.
Key Takeaways
- Odoo's ORM is a full object-relational mapper built on metaclasses that auto-generate SQL schemas, manage caching, and handle multi-company security — understanding it deeply is the difference between working with Odoo and fighting it.
- Five field categories cover every data modeling need: basic (Char, Integer, Float, Boolean, Text, Html, Date, Datetime, Binary, Selection), relational (Many2one, One2many, Many2many), computed, related, and monetary fields.
- Three inheritance mechanisms let you extend any part of Odoo without forking: class inheritance (_inherit), prototype inheritance (_inherit + _name), and delegation inheritance (_inherits).
- Controllers handle HTTP using decorators (@http.route) with JSON-RPC and HTTP endpoint patterns, authentication modes, and CORS configuration.
- Automated testing with TransactionCase and HttpCase catches regressions before they reach production — aim for 80%+ coverage on business logic.
1. Setting Up Your Development Environment
Before writing code, you need a proper development environment. Here is the recommended setup for 2026:
# Install system dependencies (Ubuntu/Debian)
# sudo apt install python3.12 python3.12-venv python3.12-dev
# sudo apt install postgresql-17 libpq-dev libxml2-dev libxslt1-dev
# Clone Odoo source (enterprise requires license)
# git clone https://github.com/odoo/odoo.git --branch 18.0 --depth 1
# git clone https://github.com/odoo/enterprise.git --branch 18.0 --depth 1
# Create virtual environment
# python3.12 -m venv odoo-venv
# source odoo-venv/bin/activate
# pip install -r odoo/requirements.txt
# Create database and start Odoo
# createdb mydb
# python odoo/odoo-bin -d mydb --addons-path=odoo/addons,enterprise,custom_addons
Create a custom_addons directory for your modules. Each module lives in its own subdirectory with a specific structure that Odoo expects.
2. Module Architecture Deep Dive
Every Odoo module follows a convention-over-configuration structure. Understanding each component's role is essential:
my_module/
├── __init__.py # Python package initializer
├── __manifest__.py # Module metadata and dependencies
├── models/
│ ├── __init__.py
│ ├── sale_order.py # Business logic models
│ └── res_partner.py # Partner extensions
├── views/
│ ├── sale_order_views.xml # Form, tree, search views
│ └── menu.xml # Menu items and actions
├── controllers/
│ ├── __init__.py
│ └── main.py # HTTP controllers
├── security/
│ ├── ir.model.access.csv # ACL rules
│ └── security.xml # Record rules and groups
├── data/
│ ├── data.xml # Default data
│ └── demo.xml # Demo data (dev only)
├── wizards/
│ ├── __init__.py
│ └── mass_update.py # TransientModel wizards
├── report/
│ ├── report_invoice.xml # QWeb report templates
│ └── report.py # Report parsers
├── static/
│ ├── description/
│ │ └── icon.png # Module icon (128x128)
│ └── src/
│ ├── js/ # OWL components
│ └── xml/ # OWL templates
└── tests/
├── __init__.py
└── test_sale_order.py # Automated tests
The Manifest File
The __manifest__.py is the heart of your module. Every field matters:
# __manifest__.py
{
'name': 'Sales Commission Engine',
'version': '18.0.1.0.0', # Odoo version.major.minor.patch
'category': 'Sales/Commission',
'summary': 'Automated commission calculations for sales teams',
'description': """
Long description with RST formatting.
Supports multi-tier commission plans.
""",
'author': 'ECOSIRE Private Limited',
'website': 'https://ecosire.com',
'license': 'LGPL-3',
'depends': ['sale', 'account', 'hr'], # Required modules
'data': [
'security/ir.model.access.csv',
'views/commission_plan_views.xml',
'views/menu.xml',
'data/commission_data.xml',
],
'demo': ['data/demo.xml'],
'installable': True,
'application': True, # Shows in Apps menu
'auto_install': False,
'external_dependencies': {
'python': ['pandas'], # pip packages
},
}
Version numbering convention: {odoo_version}.{major}.{minor}.{patch} — the first number always matches the Odoo series.
3. The ORM API: Complete Reference
Odoo's ORM is far more powerful than most developers realize. It handles schema generation, caching, access control, and multi-company filtering automatically.
Model Types
from odoo import models, fields, api
# Persistent model — stored in database
class CommissionPlan(models.Model):
_name = 'commission.plan'
_description = 'Commission Plan'
_order = 'sequence, name'
_rec_name = 'name'
name = fields.Char(string='Plan Name', required=True, index=True)
active = fields.Boolean(default=True)
sequence = fields.Integer(default=10)
company_id = fields.Many2one('res.company', default=lambda self: self.env.company)
# Transient model — auto-cleaned after ~1 hour
class CommissionWizard(models.TransientModel):
_name = 'commission.calculate.wizard'
_description = 'Calculate Commissions'
date_from = fields.Date(required=True)
date_to = fields.Date(required=True)
# Abstract model — no database table, only for inheritance
class CommissionMixin(models.AbstractModel):
_name = 'commission.mixin'
_description = 'Commission Mixin'
commission_rate = fields.Float(string='Commission %')
Field Types Deep Dive
class SaleCommission(models.Model):
_name = 'sale.commission'
_description = 'Sale Commission Record'
# Basic fields
name = fields.Char(string='Reference', required=True, copy=False,
default=lambda self: self.env['ir.sequence'].next_by_code('sale.commission'))
amount = fields.Float(string='Amount', digits=(16, 2))
percentage = fields.Float(string='Rate %', digits=(5, 2))
is_paid = fields.Boolean(string='Paid', default=False)
notes = fields.Text(string='Internal Notes')
description = fields.Html(string='Description', sanitize=True)
date = fields.Date(string='Commission Date', default=fields.Date.today)
create_datetime = fields.Datetime(string='Created', default=fields.Datetime.now)
document = fields.Binary(string='Attachment')
document_name = fields.Char(string='File Name')
# Selection field
state = fields.Selection([
('draft', 'Draft'),
('confirmed', 'Confirmed'),
('paid', 'Paid'),
('cancelled', 'Cancelled'),
], string='Status', default='draft', tracking=True)
# Relational fields
salesperson_id = fields.Many2one('res.users', string='Salesperson',
required=True, ondelete='restrict')
order_id = fields.Many2one('sale.order', string='Sale Order')
line_ids = fields.One2many('sale.commission.line', 'commission_id', string='Lines')
tag_ids = fields.Many2many('sale.commission.tag', string='Tags')
# Computed field with store and search
total_amount = fields.Monetary(string='Total', compute='_compute_total',
store=True, currency_field='currency_id')
currency_id = fields.Many2one('res.currency', related='company_id.currency_id')
@api.depends('line_ids.amount')
def _compute_total(self):
for record in self:
record.total_amount = sum(record.line_ids.mapped('amount'))
# Related field (shortcut for computed)
salesperson_email = fields.Char(related='salesperson_id.email', store=True)
CRUD Operations
# CREATE — returns new recordset
commission = self.env['sale.commission'].create({
'salesperson_id': self.env.user.id,
'order_id': order.id,
'state': 'draft',
'line_ids': [
(0, 0, {'product_id': product.id, 'amount': 150.00}), # Create
],
})
# Batch create (much faster than loop)
vals_list = [{'name': f'COM-{i}', 'amount': i * 10} for i in range(100)]
records = self.env['sale.commission'].create(vals_list)
# READ — browse by IDs
commission = self.env['sale.commission'].browse(42)
commissions = self.env['sale.commission'].browse([42, 43, 44])
# SEARCH — returns recordset matching domain
pending = self.env['sale.commission'].search([
('state', '=', 'confirmed'),
('date', '>=', '2026-01-01'),
('salesperson_id.department_id.name', '=', 'Sales'),
], order='date desc', limit=50)
# search_count — just the count
count = self.env['sale.commission'].search_count([('state', '=', 'draft')])
# search_read — search + read in one query (optimal for API calls)
data = self.env['sale.commission'].search_read(
[('state', '=', 'confirmed')],
fields=['name', 'amount', 'salesperson_id'],
limit=20, offset=0, order='amount desc'
)
# UPDATE
commission.write({'state': 'paid', 'notes': 'Paid on March 2026'})
# Batch update (all records get same values)
pending.write({'state': 'confirmed'})
# DELETE
old_drafts.unlink()
# SPECIAL: (0, 0, vals) create, (1, id, vals) update, (2, id, 0) delete
# (3, id, 0) unlink M2M, (4, id, 0) link M2M, (5, 0, 0) clear M2M
# (6, 0, [ids]) replace M2M
Domain Expressions
Domains are the query language of Odoo. Master them:
# Basic operators: =, !=, >, >=, <, <=, like, ilike, in, not in
# Special operators: =like (SQL LIKE), =ilike (case-insensitive LIKE)
# child_of, parent_of — for hierarchical models
domain = [
'|', # OR (next two leaves)
('state', '=', 'confirmed'),
'&', # AND (next two leaves)
('state', '=', 'draft'),
('amount', '>', 1000),
('salesperson_id', '!=', False), # Implicit AND with above
('date', '>=', '2026-01-01'),
('tag_ids.name', 'in', ['priority', 'vip']), # Dot notation for relations
]
# Negation
domain = [('state', 'not in', ['cancelled', 'paid'])]
# NULL checks
domain = [('notes', '!=', False)] # Has notes
domain = [('notes', '=', False)] # No notes
4. Inheritance Patterns
Odoo provides three inheritance mechanisms that let you extend any module without modifying its source code. Class inheritance adds fields and methods to existing models. Prototype inheritance creates new models based on existing ones with separate database tables. Delegation inheritance links models through composition, creating records in both tables automatically.
Class Inheritance (Most Common)
class SaleOrderInherit(models.Model):
_inherit = 'sale.order' # Extend existing model
# Add new fields
commission_plan_id = fields.Many2one('commission.plan', string='Commission Plan')
commission_amount = fields.Monetary(compute='_compute_commission')
@api.depends('amount_total', 'commission_plan_id.rate')
def _compute_commission(self):
for order in self:
rate = order.commission_plan_id.rate or 0
order.commission_amount = order.amount_total * rate / 100
# Override existing method
def action_confirm(self):
res = super().action_confirm() # Always call super()
for order in self:
if order.commission_plan_id:
order._create_commission_record()
return res
def _create_commission_record(self):
self.ensure_one()
self.env['sale.commission'].create({
'order_id': self.id,
'salesperson_id': self.user_id.id,
'amount': self.commission_amount,
})
Prototype Inheritance
class ProjectCommission(models.Model):
_name = 'project.commission'
_inherit = 'sale.commission' # Copy structure to new model
_description = 'Project-based Commission'
# Has all fields from sale.commission PLUS its own table
project_id = fields.Many2one('project.project', required=True)
milestone_id = fields.Many2one('project.milestone')
Delegation Inheritance
class CommissionEmployee(models.Model):
_name = 'commission.employee'
_inherits = {'hr.employee': 'employee_id'} # Delegation
_description = 'Commission-eligible Employee'
employee_id = fields.Many2one('hr.employee', required=True, ondelete='cascade')
commission_plan_id = fields.Many2one('commission.plan')
lifetime_earnings = fields.Monetary()
# Can access employee fields directly: record.name, record.department_id
5. Business Logic Patterns
Constraints
from odoo.exceptions import ValidationError
class CommissionPlan(models.Model):
_name = 'commission.plan'
rate = fields.Float(required=True)
name = fields.Char(required=True)
# Python constraint
@api.constrains('rate')
def _check_rate(self):
for plan in self:
if not 0 <= plan.rate <= 100:
raise ValidationError(
f"Commission rate must be between 0 and 100. Got: {plan.rate}"
)
# SQL constraint (faster, database-level)
_sql_constraints = [
('name_unique', 'UNIQUE(name, company_id)',
'Commission plan name must be unique per company.'),
('rate_positive', 'CHECK(rate >= 0)',
'Commission rate must be positive.'),
]
Onchange Methods
@api.onchange('commission_plan_id')
def _onchange_plan(self):
"""Triggered in UI when field changes. Sets defaults and warnings."""
if self.commission_plan_id:
self.percentage = self.commission_plan_id.rate
if self.commission_plan_id.rate > 30:
return {
'warning': {
'title': 'High Commission Rate',
'message': 'This plan has a rate above 30%. Please confirm with management.',
}
}
Workflow with State Machine
class SaleCommission(models.Model):
_name = 'sale.commission'
state = fields.Selection([
('draft', 'Draft'),
('confirmed', 'Confirmed'),
('approved', 'Approved'),
('paid', 'Paid'),
('cancelled', 'Cancelled'),
], default='draft', tracking=True)
def action_confirm(self):
for rec in self.filtered(lambda r: r.state == 'draft'):
rec.state = 'confirmed'
rec.message_post(body="Commission confirmed.")
def action_approve(self):
self.filtered(lambda r: r.state == 'confirmed').write({
'state': 'approved',
'approved_by': self.env.user.id,
'approved_date': fields.Datetime.now(),
})
def action_pay(self):
for rec in self.filtered(lambda r: r.state == 'approved'):
rec._process_payment()
rec.state = 'paid'
def action_cancel(self):
allowed = self.filtered(lambda r: r.state in ('draft', 'confirmed'))
allowed.write({'state': 'cancelled'})
6. Controllers and HTTP Endpoints
from odoo import http
from odoo.http import request, Response
import json
class CommissionController(http.Controller):
# JSON-RPC endpoint (used by Odoo's internal JS framework)
@http.route('/commission/calculate', type='json', auth='user', methods=['POST'])
def calculate_commission(self, order_id, **kwargs):
order = request.env['sale.order'].browse(order_id)
if not order.exists():
return {'error': 'Order not found'}
commission = order.commission_amount
return {'order_id': order_id, 'commission': commission}
# HTTP endpoint (REST-style, for external integrations)
@http.route('/api/v1/commissions', type='http', auth='api_key',
methods=['GET'], csrf=False)
def list_commissions(self, **kwargs):
domain = [('state', '!=', 'cancelled')]
if kwargs.get('salesperson_id'):
domain.append(('salesperson_id', '=', int(kwargs['salesperson_id'])))
commissions = request.env['sale.commission'].search_read(
domain,
fields=['name', 'amount', 'state', 'date'],
limit=int(kwargs.get('limit', 50)),
offset=int(kwargs.get('offset', 0)),
)
return Response(
json.dumps({'data': commissions, 'count': len(commissions)}),
content_type='application/json',
status=200,
)
# File download endpoint
@http.route('/commission/report/<int:commission_id>', type='http', auth='user')
def download_report(self, commission_id, **kwargs):
commission = request.env['sale.commission'].browse(commission_id)
pdf = request.env['ir.actions.report']._render_qweb_pdf(
'my_module.commission_report', commission.ids
)
return request.make_response(
pdf[0],
headers=[
('Content-Type', 'application/pdf'),
('Content-Disposition', f'attachment; filename=commission_{commission_id}.pdf'),
]
)
Authentication Modes
| Mode | Description | Use Case |
|---|---|---|
auth='user' | Requires login session | Internal pages, user actions |
auth='public' | Session optional, can be anonymous | Website pages |
auth='none' | No session, no env | Health checks, static endpoints |
auth='api_key' | API key in header | External REST integrations |
7. Security: ACL and Record Rules
Access Control Lists (ir.model.access.csv)
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
access_commission_user,commission.user,model_sale_commission,sales_team.group_sale_salesman,1,0,0,0
access_commission_manager,commission.manager,model_sale_commission,sales_team.group_sale_manager,1,1,1,0
access_commission_admin,commission.admin,model_sale_commission,base.group_system,1,1,1,1
Record Rules (security.xml)
<record id="commission_own_rule" model="ir.rule">
<field name="name">Salesperson sees own commissions</field>
<field name="model_id" ref="model_sale_commission"/>
<field name="groups" eval="[(4, ref('sales_team.group_sale_salesman'))]"/>
<field name="domain_force">[('salesperson_id', '=', user.id)]</field>
</record>
<record id="commission_company_rule" model="ir.rule">
<field name="name">Company-level commission isolation</field>
<field name="model_id" ref="model_sale_commission"/>
<field name="global" eval="True"/>
<field name="domain_force">[('company_id', 'in', company_ids)]</field>
</record>
8. Debugging Techniques
Using the Odoo Shell
# Start interactive shell with full ORM access
python odoo-bin shell -d mydb --addons-path=addons,custom_addons
# In shell:
>>> orders = env['sale.order'].search([('state', '=', 'sale')], limit=5)
>>> for o in orders:
... print(f"{o.name}: {o.amount_total} - {o.partner_id.name}")
>>> env.cr.commit() # Commit changes made in shell
Logging
import logging
_logger = logging.getLogger(__name__)
class SaleCommission(models.Model):
_name = 'sale.commission'
def action_confirm(self):
_logger.info("Confirming commission %s for user %s", self.name, self.env.user.name)
try:
self._validate_commission()
except Exception as e:
_logger.exception("Failed to confirm commission %s: %s", self.name, e)
raise
Performance Profiling
# Enable SQL logging in odoo.conf:
# log_level = debug_sql
# Or use the profiler decorator
from odoo.tools.profiler import profile
@profile('/tmp/commission_profile')
def action_bulk_calculate(self):
# This will generate a profile dump you can analyze
for order in self.env['sale.order'].search([]):
order._calculate_commission()
Common performance pitfalls:
- Accessing relational fields in loops (N+1 queries) — use
read()ormapped()instead - Calling
search()inside loops — batch your searches - Not using
store=Trueon frequently filtered computed fields - Forgetting
sudo()causes extra access-right checks on every operation
9. Automated Testing
from odoo.tests.common import TransactionCase, HttpCase, tagged
from odoo.exceptions import ValidationError
@tagged('post_install', '-at_install')
class TestCommission(TransactionCase):
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.partner = cls.env['res.partner'].create({'name': 'Test Customer'})
cls.user = cls.env['res.users'].create({
'name': 'Test Salesperson',
'login': 'test_sales',
'groups_id': [(4, cls.env.ref('sales_team.group_sale_salesman').id)],
})
cls.plan = cls.env['commission.plan'].create({
'name': 'Standard 10%',
'rate': 10.0,
})
def test_commission_calculation(self):
"""Commission amount should be 10% of order total."""
order = self.env['sale.order'].create({
'partner_id': self.partner.id,
'user_id': self.user.id,
'commission_plan_id': self.plan.id,
})
# Add order line...
self.assertAlmostEqual(order.commission_amount, 100.0, places=2)
def test_rate_constraint(self):
"""Rates above 100 should raise ValidationError."""
with self.assertRaises(ValidationError):
self.env['commission.plan'].create({
'name': 'Invalid Plan',
'rate': 150.0,
})
def test_state_machine(self):
"""Commission should follow draft -> confirmed -> paid flow."""
commission = self.env['sale.commission'].create({
'salesperson_id': self.user.id,
'amount': 500,
})
self.assertEqual(commission.state, 'draft')
commission.action_confirm()
self.assertEqual(commission.state, 'confirmed')
# HTTP/Tour tests
@tagged('post_install', '-at_install')
class TestCommissionTour(HttpCase):
def test_commission_dashboard(self):
"""Test that the commission dashboard loads correctly."""
self.start_tour('/web#action=commission.action_dashboard',
'commission_dashboard_tour', login='admin')
Run tests with:
# Run all tests for a module
python odoo-bin -d testdb -i my_module --test-enable --stop-after-init
# Run specific test class
python odoo-bin -d testdb --test-tags /my_module:TestCommission --stop-after-init
# Run with coverage
coverage run odoo-bin -d testdb -i my_module --test-enable --stop-after-init
coverage report --include="custom_addons/my_module/*"
10. Advanced Patterns
Scheduled Actions (Cron Jobs)
<record id="ir_cron_monthly_commission" model="ir.cron">
<field name="name">Calculate Monthly Commissions</field>
<field name="model_id" ref="model_sale_commission"/>
<field name="state">code</field>
<field name="code">model._cron_calculate_monthly()</field>
<field name="interval_number">1</field>
<field name="interval_type">months</field>
<field name="numbercall">-1</field>
</record>
Multi-Company Patterns
class CommissionPlan(models.Model):
_name = 'commission.plan'
company_id = fields.Many2one('res.company', required=True,
default=lambda self: self.env.company)
# Use sudo() carefully — only when crossing company boundaries is intended
def get_global_plans(self):
return self.sudo().search([('global_plan', '=', True)])
Mixin Pattern for Reusable Logic
class CommissionMixin(models.AbstractModel):
_name = 'commission.mixin'
_description = 'Adds commission tracking to any model'
commission_ids = fields.One2many('sale.commission', 'res_id')
commission_total = fields.Monetary(compute='_compute_commission_total', store=True)
@api.depends('commission_ids.amount')
def _compute_commission_total(self):
for record in self:
record.commission_total = sum(record.commission_ids.mapped('amount'))
# Apply to any model via class inheritance
class SaleOrder(models.Model):
_inherit = ['sale.order', 'commission.mixin']
Real-World Example: Complete Commission Module
For a complete, production-ready commission module example, visit ECOSIRE's open-source Odoo modules where we publish connectors, utilities, and business modules following all best practices covered in this guide.
Our Odoo development team has built over 60 Odoo modules using these exact patterns. If you need custom Odoo Python development — from a single field addition to a full ERP implementation — contact our team for a free consultation.
Frequently Asked Questions
What Python version does Odoo 18 require?
Odoo 18 requires Python 3.10 or higher, with Python 3.12 being the recommended version. Odoo 17 supports Python 3.10+. Always check the official requirements.txt for the exact dependencies needed for your Odoo version.
How do I debug Odoo ORM queries?
Enable SQL logging by setting log_level to debug_sql in your odoo.conf file, or pass --log-sql on the command line. You can also use self.env.cr.sql_log to trace queries in code. For performance profiling, use the built-in @profile decorator from odoo.tools.profiler.
Should I use _inherit or _inherits for extending models?
Use _inherit (class inheritance) 95% of the time — it adds fields and methods to an existing model's table. Use _inherits (delegation) only when you need a separate model that transparently accesses another model's fields, like creating a specialized employee type that inherits all hr.employee fields.
How do I handle multi-company data isolation?
Add a company_id Many2one field to your model with a default lambda returning self.env.company. Then create a global ir.rule with the domain [('company_id', 'in', company_ids)]. This ensures users only see records from companies they have access to, which is the standard Odoo multi-company pattern.
What is the difference between @api.depends and @api.onchange?
@api.depends triggers when specified fields change in the database and is used for stored computed fields — it works for both UI and programmatic changes. @api.onchange triggers only in the UI when a user modifies a field and is used for setting defaults or showing warnings. Use @api.depends for data integrity and @api.onchange for UX improvements.
How do I test my Odoo module effectively?
Use TransactionCase for unit tests that test business logic — each test runs in a transaction that is rolled back. Use HttpCase for testing controllers and web tours. Tag tests with @tagged('post_install', '-at_install') to run after module installation. Aim for at least 80% code coverage on business logic methods.
Next Steps
Building production-grade Odoo modules requires both technical skill and domain expertise. Start with simple class inheritance, then progress to full custom modules. For further reading:
- Odoo Custom Module Development Guide — Higher-level overview of module development
- Odoo REST API Tutorial — External integrations with Odoo
- Best ERP Software 2026 — Understanding Odoo's position in the market
Need expert Odoo developers? ECOSIRE's Odoo development team has delivered 200+ custom modules for businesses across 40 countries. From commission engines to full marketplace integrations, our team writes production-ready code following every pattern in this guide. Get a free consultation.
Written by
ECOSIRE TeamTechnical Writing
The ECOSIRE technical writing team covers Odoo ERP, Shopify eCommerce, AI agents, Power BI analytics, GoHighLevel automation, and enterprise software best practices. Our guides help businesses make informed technology decisions.
Related Articles
AI-Powered Customer Segmentation: From RFM to Predictive Clustering
Learn how AI transforms customer segmentation from static RFM analysis to dynamic predictive clustering. Implementation guide with Python, Odoo, and real ROI data.
AI for Supply Chain Optimization: Visibility, Prediction & Automation
Transform supply chain operations with AI: demand sensing, supplier risk scoring, route optimization, warehouse automation, and disruption prediction. 2026 guide.
B2B E-commerce Strategy: Build a Wholesale Online Business in 2026
Master B2B e-commerce with strategies for wholesale pricing, account management, credit terms, punchout catalogs, and Odoo B2B portal configuration.