Skip to content

Multi-Company Support

Netforce provides comprehensive multi-company (multi-tenant) support, allowing a single installation to serve multiple organizations with complete data isolation and company-specific configurations.

Overview

The multi-company system provides:

  • Data Isolation - Each company's data is completely separated
  • Shared Configuration - Common settings and templates across companies
  • Company Context - Automatic filtering based on active company
  • User Access Control - Users can access specific companies
  • Cross-Company Operations - When explicitly needed

Core Concepts

Company Model

The company model is the foundation of multi-company support:

class Company(Model):
    _name = "company"
    _fields = {
        "name": fields.Char("Company Name", required=True),
        "code": fields.Char("Company Code", required=True),
        "logo": fields.File("Logo"),
        "website": fields.Char("Website"),
        "email": fields.Char("Email"),
        "phone": fields.Char("Phone"),
        "address": fields.Text("Address"),
        "currency_id": fields.Many2One("currency", "Base Currency"),
        "active": fields.Boolean("Active"),
    }

Multi-Company Models

Enable multi-company support by adding the _multi_company flag:

class Invoice(Model):
    _name = "account.invoice"
    _multi_company = True  # Enable multi-company support

    _fields = {
        "number": fields.Char("Number", required=True),
        "customer_id": fields.Many2One("contact", "Customer"),
        "company_id": fields.Many2One("company", "Company", required=True),
        # ... other fields
    }

    _defaults = {
        "company_id": lambda *a: get_active_company(),
    }

Implementation Patterns

1. Basic Multi-Company Model

from netforce.access import get_active_company, set_active_company

class Product(Model):
    _name = "product.product"
    _multi_company = True
    _content_search = True  # Also supports multi-company search

    _fields = {
        "code": fields.Char("Product Code", required=True, search=True),
        "name": fields.Char("Product Name", required=True, search=True),
        "price": fields.Decimal("Price"),
        "company_id": fields.Many2One("company", "Company", required=True),
        # Note: company_id is automatically added when _multi_company = True
    }

    # Unique constraint per company
    _key = ["code", "company_id"]

    _defaults = {
        "company_id": lambda *a: get_active_company(),
    }

    def search(self, condition=None, context={}):
        """Search automatically filters by active company"""
        # Framework automatically adds company filter
        # condition becomes: condition + [["company_id", "=", active_company]]
        return super().search(condition, context)

2. Company Context Management

def get_company_data(self, company_ids=None):
    """Get data for specific companies"""
    if not company_ids:
        company_ids = [get_active_company()]

    results = {}

    for company_id in company_ids:
        # Switch company context
        original_company = get_active_company()
        try:
            set_active_company(company_id)

            # Data will be filtered by this company
            invoices = get_model("account.invoice").search_browse([
                ["state", "=", "draft"]
            ])

            results[company_id] = {
                "draft_invoices": len(invoices),
                "total_amount": sum(inv.amount_total for inv in invoices)
            }

        finally:
            # Always restore original company
            set_active_company(original_company)

    return results

3. Cross-Company Operations

For operations that need to work across companies:

def get_all_companies_data(self):
    """Get data from all companies (admin operation)"""
    # Temporarily disable company filtering
    original_company = get_active_company()

    try:
        # Switch to admin context
        set_active_company(None)  # No company filtering

        # Now searches return data from all companies
        all_invoices = get_model("account.invoice").search_browse([])

        # Group by company
        by_company = {}
        for invoice in all_invoices:
            company_id = invoice.company_id.id
            if company_id not in by_company:
                by_company[company_id] = []
            by_company[company_id].append(invoice)

        return by_company

    finally:
        set_active_company(original_company)

def consolidate_reports(self, company_ids):
    """Create consolidated report across companies"""
    consolidated_data = {}

    for company_id in company_ids:
        set_active_company(company_id)

        # Get company-specific data
        company_data = self.get_financial_data()
        consolidated_data[company_id] = company_data

    # Process consolidated data
    return self.merge_company_data(consolidated_data)

User Access Control

Company-User Relationships

Users can have access to multiple companies:

class User(Model):
    _name = "base.user"
    _fields = {
        "login": fields.Char("Login", required=True),
        "name": fields.Char("Name", required=True),
        "companies": fields.Many2Many("company", "User Companies"),
        "company_id": fields.Many2One("company", "Default Company"),
        # ... other fields
    }

    def get_accessible_companies(self, user_id):
        """Get companies user can access"""
        user = self.browse(user_id)
        return [comp.id for comp in user.companies]

    def switch_company(self, user_id, company_id):
        """Switch user's active company"""
        user = self.browse(user_id)

        # Check if user has access to this company
        company_ids = [comp.id for comp in user.companies]
        if company_id not in company_ids:
            raise Exception("User does not have access to this company")

        # Update user's default company
        user.write({"company_id": company_id})

        # Set active company in session
        set_active_company(company_id)

Permission Filtering

Implement company-based permissions:

def check_company_access(self, user_id, company_id):
    """Check if user can access company data"""
    user = get_model("base.user").browse(user_id)
    accessible_companies = [comp.id for comp in user.companies]

    return company_id in accessible_companies

@company_access_required
def sensitive_operation(self, ids, context={}):
    """Operation that requires company access validation"""
    user_id = get_active_user()
    company_id = get_active_company()

    if not self.check_company_access(user_id, company_id):
        raise Exception("Insufficient company access")

    # Proceed with operation
    return super().sensitive_operation(ids, context)

Configuration Management

Shared vs Company-Specific Settings

class Settings(Model):
    _name = "settings"
    _multi_company = True

    _fields = {
        # Company-specific settings
        "company_id": fields.Many2One("company", "Company"),
        "invoice_sequence_id": fields.Many2One("sequence", "Invoice Sequence"),
        "default_currency_id": fields.Many2One("currency", "Default Currency"),
        "logo": fields.File("Company Logo"),

        # Shared settings (same across all companies)
        "system_name": fields.Char("System Name"),
        "maintenance_mode": fields.Boolean("Maintenance Mode"),
    }

    def get_setting(self, setting_name, company_id=None):
        """Get setting value with company context"""
        if company_id:
            settings = self.search_browse([["company_id", "=", company_id]])
        else:
            settings = self.search_browse([])

        if settings:
            return getattr(settings[0], setting_name, None)
        return None

# Global settings (not multi-company)
class GlobalSettings(Model):
    _name = "global.settings"
    _multi_company = False  # Explicitly disable multi-company

    _fields = {
        "backup_frequency": fields.Selection([
            ["daily", "Daily"],
            ["weekly", "Weekly"]
        ], "Backup Frequency"),
        "max_file_size": fields.Integer("Max File Size (MB)"),
    }

Database Schema Considerations

Company Field Indexing

class MultiCompanyModel(Model):
    _name = "my.model"
    _multi_company = True

    # Add database indexes for company-based queries
    _sql_constraints = [
        ("company_code_uniq", "unique (company_id, code)", 
         "Code must be unique per company"),
    ]

    # Add indexes for performance
    def _create_indexes(self):
        """Create database indexes for multi-company queries"""
        database.execute("""
            CREATE INDEX IF NOT EXISTS my_model_company_state_idx 
            ON my_model (company_id, state)
        """)

        database.execute("""
            CREATE INDEX IF NOT EXISTS my_model_company_date_idx 
            ON my_model (company_id, date DESC)
        """)

Data Migration Between Companies

def migrate_data_between_companies(self, from_company_id, to_company_id, record_ids):
    """Migrate records from one company to another"""

    # Validate companies exist
    from_company = get_model("company").browse(from_company_id)
    to_company = get_model("company").browse(to_company_id)

    migrated_records = []

    for record_id in record_ids:
        try:
            # Switch to source company context
            set_active_company(from_company_id)
            source_record = self.browse(record_id)

            # Read record data
            record_data = source_record.read()[0]

            # Remove system fields
            system_fields = ['id', 'create_time', 'create_uid', 'write_time', 'write_uid']
            for field in system_fields:
                record_data.pop(field, None)

            # Update company
            record_data['company_id'] = to_company_id

            # Switch to target company context
            set_active_company(to_company_id)

            # Create record in target company
            new_record_id = self.create(record_data)
            migrated_records.append(new_record_id)

            # Optional: Archive original record
            set_active_company(from_company_id)
            source_record.write({"active": False})

        except Exception as e:
            print(f"Failed to migrate record {record_id}: {str(e)}")

    return migrated_records

API Integration

Company-Aware API Endpoints

# JSON-RPC with company context
def execute_with_company(model_name, method, args, company_id, context={}):
    """Execute method in specific company context"""
    original_company = get_active_company()

    try:
        set_active_company(company_id)

        # Execute method with company context
        result = get_model(model_name).call(method, args, context)

        return result

    finally:
        set_active_company(original_company)

# REST API example
class CompanyAPIController(Controller):
    _path = "/api/v1/companies/{company_id}"

    def get(self, company_id):
        """Get data for specific company"""
        # Validate company access
        user_id = get_active_user()
        if not self.check_company_access(user_id, int(company_id)):
            return {"error": "Access denied"}, 403

        # Set company context
        set_active_company(int(company_id))

        # Return company data
        return {
            "company": get_model("company").browse(int(company_id)).read()[0],
            "stats": self.get_company_stats()
        }

Frontend Integration

Company Switching UI

// Company selector component
class CompanySelector extends React.Component {
    state = {
        companies: [],
        activeCompany: null
    };

    componentDidMount() {
        this.loadCompanies();
    }

    loadCompanies() {
        rpc.execute("base.user", "get_user_companies", [], {}, (err, companies) => {
            this.setState({companies});
        });
    }

    switchCompany(companyId) {
        rpc.execute("base.user", "switch_company", [companyId], {}, (err, result) => {
            if (!err) {
                // Refresh page to load new company data
                window.location.reload();
            }
        });
    }

    render() {
        return (
            <div className="company-selector">
                <select onChange={e => this.switchCompany(parseInt(e.target.value))}>
                    {this.state.companies.map(company => (
                        <option key={company.id} value={company.id}>
                            {company.name}
                        </option>
                    ))}
                </select>
            </div>
        );
    }
}

Company-Aware Components

// Automatic company filtering in components
class InvoiceList extends React.Component {
    loadData() {
        // Data automatically filtered by active company
        rpc.execute("account.invoice", "search_read", [
            [["state", "=", "draft"]]  // Company filter added automatically
        ], {}, (err, invoices) => {
            this.setState({invoices});
        });
    }

    // Cross-company search (admin only)
    loadAllCompaniesData() {
        rpc.execute("account.invoice", "search_read", [
            []  // No filters
        ], {
            company_id: null  // Disable company filtering
        }, (err, invoices) => {
            // Groups invoices by company
            const byCompany = invoices.reduce((acc, inv) => {
                const companyId = inv.company_id[0];
                if (!acc[companyId]) acc[companyId] = [];
                acc[companyId].push(inv);
                return acc;
            }, {});

            this.setState({invoicesByCompany: byCompany});
        });
    }
}

Performance Optimization

Company-Based Caching

# Cache data per company
_company_cache = {}

def get_company_cache(cache_key, company_id=None):
    """Get cached data for specific company"""
    if company_id is None:
        company_id = get_active_company()

    full_key = f"{company_id}:{cache_key}"
    return _company_cache.get(full_key)

def set_company_cache(cache_key, data, company_id=None):
    """Cache data for specific company"""
    if company_id is None:
        company_id = get_active_company()

    full_key = f"{company_id}:{cache_key}"
    _company_cache[full_key] = data

def clear_company_cache(company_id=None):
    """Clear cache for specific company"""
    if company_id is None:
        company_id = get_active_company()

    keys_to_remove = [k for k in _company_cache.keys() if k.startswith(f"{company_id}:")]
    for key in keys_to_remove:
        del _company_cache[key]

Query Optimization

# Efficient company-based queries
def get_company_summary(self, company_ids):
    """Get summary data for multiple companies efficiently"""
    summaries = {}

    # Single query for all companies
    sql = """
        SELECT 
            company_id,
            COUNT(*) as total_records,
            SUM(amount_total) as total_amount,
            AVG(amount_total) as avg_amount
        FROM account_invoice 
        WHERE company_id IN %s 
        GROUP BY company_id
    """

    results = database.get(sql, [tuple(company_ids)])

    for row in results:
        summaries[row[0]] = {
            "total_records": row[1],
            "total_amount": row[2],
            "avg_amount": row[3]
        }

    return summaries

Best Practices

1. Always Include Company Context

# Good: Always consider company context
def create_invoice(self, customer_id, lines):
    company_id = get_active_company()
    return get_model("account.invoice").create({
        "customer_id": customer_id,
        "company_id": company_id,
        "lines": lines
    })

# Bad: Forgetting company context
def create_invoice_bad(self, customer_id, lines):
    # Missing company_id - will fail or use wrong company
    return get_model("account.invoice").create({
        "customer_id": customer_id,
        "lines": lines
    })

2. Handle Company Switching Safely

def safe_company_operation(self, target_company_id, operation_func):
    """Safely execute operation in different company context"""
    original_company = get_active_company()

    try:
        set_active_company(target_company_id)
        return operation_func()
    except Exception as e:
        # Log error with company context
        self.log_error(f"Company {target_company_id} operation failed: {str(e)}")
        raise
    finally:
        # Always restore original company
        set_active_company(original_company)

3. Validate Company Access

def validate_company_access(self, user_id, company_id, operation="read"):
    """Validate user has required access to company"""
    user = get_model("base.user").browse(user_id)

    # Check if user has access to company
    user_companies = [comp.id for comp in user.companies]
    if company_id not in user_companies:
        raise Exception(f"User {user_id} does not have access to company {company_id}")

    # Additional operation-specific checks
    if operation == "admin":
        if not user.is_admin:
            raise Exception("Admin access required")

Troubleshooting

Common Issues

  1. Data Leakage Between Companies
  2. Always check _multi_company = True on models
  3. Verify company filters in custom queries
  4. Use company context properly

  5. Permission Errors

  6. Check user-company relationships
  7. Validate company access in API calls
  8. Ensure proper company switching

  9. Performance Issues

  10. Add company indexes to frequently queried tables
  11. Use company-specific caching
  12. Optimize cross-company operations

Debugging

def debug_company_context():
    """Debug current company context"""
    current_company = get_active_company()
    current_user = get_active_user()

    user = get_model("base.user").browse(current_user)
    user_companies = [comp.id for comp in user.companies]

    print(f"Current Company: {current_company}")
    print(f"Current User: {current_user}")
    print(f"User Companies: {user_companies}")
    print(f"Has Access: {current_company in user_companies}")

Next Steps

  • Learn about Security for company-based permissions
  • Explore Sequences for company-specific numbering
  • Check Workflows for company-aware approval processes
  • Review Advanced Patterns for complex multi-company scenarios