first commit

This commit is contained in:
Suherdy Yacob 2026-05-25 08:42:22 +07:00
commit 97a1b63977
7 changed files with 520 additions and 0 deletions

140
.gitignore vendored Normal file
View File

@ -0,0 +1,140 @@
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
*$py.class
# C extensions
*.so
# Distribution / packaging
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
share/python-wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST
# Installer logs
pip-log.txt
pip-delete-this-directory.txt
# Unit test / coverage reports
htmlcov/
.tox/
.nox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
*.py,cover
.hypothesis/
.pytest_cache/
cover/
# Translation files
*.mo
*.pot
# Django stuff:
*.log
local_settings.py
db.sqlite3
db.sqlite3-journal
# Sphinx documentation
_build/
# PyBuilder
.pybuilder/
target/
# Jupyter Notebook
.ipynb_checkpoints
# IPython
profile_default/
ipython_config.py
# pyenv
# For a library or package, you might want to ignore these files since the values are
# specific to the developer's environment:
# .python-version
# pipenv
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
# However, in case of collaboration, if you want to share the environment, uncomment this:
#Lockfile
# poetry
# Similar to Pipfile.lock, poetry.lock contains specific versions of dependencies
# uncomment this if you want to lock the dependency versions in your project
#poetry.lock
# pdm
# Similar to Pipfile.lock, pdm.lock contains specific versions of dependencies
#pdm.lock
# pdm local configuration
.pdm-python.path
.pdm-build/
# venv
.venv/
venv/
ENV/
env/
# For more virtual environment tools, see:
# https://realpython.com/python-virtual-environments-a-primer/
# Spyder project settings
.spyderproject
.spyproject
# Rope project settings
.ropeproject
# mkdocs documentation
/site
# mypy
.mypy_cache/
.dmypy.json
dmypy.json
# Pyre type checker
.pyre/
# pytype
.pytype/
# Cython debug symbols
cython_debug/
# Editors and IDEs
.idea/
.vscode/
*.swp
*.swo
*~
# OS-specific files
.DS_Store
.DS_Store?
._*
.Spotlight-V100
.Trashes
ehthumbs.db
Thumbs.db

74
README.md Normal file
View File

@ -0,0 +1,74 @@
# Multi-Company Product & Category Settings Sync
This Odoo 19 module automatically shares and synchronizes company-dependent product and product category settings from a parent company to all active branch (descendant) companies recursively.
## Overview
In Odoo, standard product templates (`product.template`) and product categories (`product.category`) are globally accessible if their `company_id` is set to `False` (empty). However, accounting, taxes, and routing settings remain company-dependent:
1. **Accounting Accounts**: Stored as `jsonb` fields on the model in Odoo 19, mapping company IDs directly to account record IDs.
2. **Taxes & Routes**: Many-to-many relationships where each company must map its own specific records.
This module automates the propagation of these settings down the company hierarchy, minimizing manual configuration errors across multiple branch companies.
## Features
### 🏢 Recursive Multi-Company Synchronization
Propagates all settings down the active company's branch hierarchy recursively using Odoo's fast `'child_of'` operator.
### 🧮 Product Category Sync (`product.category`)
Synchronizes the following fields from the parent category to branch categories:
- **Inventory Valuation**: Periodic or Automated (`property_valuation`)
- **Costing Method**: Standard, Average (AVCO), or FIFO (`property_cost_method`)
- **Income Account** (`property_account_income_categ_id`)
- **Expense Account** (`property_account_expense_categ_id`)
- **Price Difference Account** (`property_price_difference_account_id`)
- **Stock Production Cost Account** (`property_stock_account_production_cost_id`)
- **Stock Valuation Account** (`property_stock_valuation_account_id`)
- **Note**: `account_stock_variation_id` (Stock Variation Account) in Odoo 19 is a global field (`company_dependent=False`) and is shared automatically without additional sync code.
- **Valuation Journal Excluded**: The Stock Journal (`property_stock_journal`) is completely excluded from the sync to remain branch-company specific.
### 📦 Product Template Sync (`product.template`)
Synchronizes the following fields from the parent product template to branch templates:
- **Income Account** (`property_account_income_id`)
- **Expense Account** (`property_account_expense_id`)
- **Price Difference Account** (`property_price_difference_account_id`)
- **Stock Inventory Location** (`property_stock_inventory`)
- **Stock Production Location** (`property_stock_production`)
- **Customer Taxes** (`taxes_id`) & **Vendor Taxes** (`supplier_taxes_id`)
- **Logistics Routes** (`route_ids`)
- **Note**: Many-to-Many taxes and routes updates are performed safely to prevent standard Odoo writes from clearing the mappings of other unrelated branch companies.
- **Cost Price Excluded**: Cost price (`standard_price`) is excluded from synchronization to allow branch-specific costing.
### 🔄 Intelligent Branch Counterpart Matching
When mapping a parent company record to a child company:
1. **Accounts**: Matches by account `code`. (Falls back to the parent record if no branch-specific account is found, as accounts in Odoo 19 can be shared across companies).
2. **Taxes**: Matches by tax `name`, `type_tax_use`, `amount`, and `amount_type`.
3. **Routes**: Matches by route `name`.
4. **Locations**: Matches by `complete_name` first, then by `name`.
5. **Universal Fallback**: If no specific branch-company counterpart exists, the sync falls back to preserving the parent record value for the branch (to support retail setups where branch companies share the parent company's chart of accounts and taxes).
## Installation
1. Copy the `multicompany_product_sync` folder to your custom addons directory.
2. Restart your Odoo server.
3. Activate the developer mode in Odoo.
4. Go to **Apps** -> **Update Apps List**.
5. Search for `Multi-Company Product & Category Settings Sync` and click **Activate**.
## Technical Details
### Infinite Loop Prevention
All synchronization logic utilizes context variables (e.g., `skip_company_dependent_sync=True`) when executing writes on branch companies, ensuring that child company updates do not trigger redundant and infinite synchronization loops.
### Directory Structure
```
multicompany_product_sync/
├── __init__.py
├── __manifest__.py
├── README.md
├── .gitignore
└── models/
├── __init__.py
├── product_category.py
└── product_template.py
```

2
__init__.py Normal file
View File

@ -0,0 +1,2 @@
# -*- coding: utf-8 -*-
from . import models

32
__manifest__.py Normal file
View File

@ -0,0 +1,32 @@
# -*- coding: utf-8 -*-
{
'name': 'Multi-Company Product & Category Settings Sync',
'version': '19.0.1.0.0',
'summary': 'Automatically share and synchronize product and product category settings from parent company to branch companies recursively.',
'description': """
Multi-Company Product & Category Settings Sync
================================================
Synchronizes the following settings from a parent company to all active branch companies recursively:
- Product Category:
- Valuation & Costing Methods
- Income & Expense accounts
- Stock Valuation, Stock Input, Stock Output accounts (excluding stock journal to keep it branch-specific)
- Product Template:
- Income, Expense, and Price Difference accounts
- Customers and Vendors taxes (mapped by name and amount)
- Routes (mapped by name)
- Stock Inventory and Production locations (mapped by path)
""",
'author': 'Suherdy Yacob',
'category': 'Sales/Sales',
'depends': [
'product',
'account',
'stock',
],
'data': [],
'installable': True,
'auto_install': False,
'application': False,
'license': 'LGPL-3',
}

3
models/__init__.py Normal file
View File

@ -0,0 +1,3 @@
# -*- coding: utf-8 -*-
from . import product_category
from . import product_template

122
models/product_category.py Normal file
View File

@ -0,0 +1,122 @@
# -*- coding: utf-8 -*-
from odoo import api, fields, models
class ProductCategory(models.Model):
_inherit = 'product.category'
def _get_counterpart_record(self, record, target_company):
"""Helper to find counterpart record in target company, fallback to parent if not found."""
if not record:
return record.browse()
has_company_ids = 'company_ids' in record._fields
has_company_id = 'company_id' in record._fields
# If the record is already compatible with target company, reuse it directly
if has_company_ids and target_company in record.company_ids:
return record
if has_company_id and record.company_id and record.company_id.id == target_company.id:
return record
if not has_company_ids and not has_company_id:
return record
if has_company_id and not record.company_id:
return record
model_name = record._name
domain = []
if has_company_ids:
domain.append(('company_ids', 'in', [target_company.id]))
else:
domain.append(('company_id', '=', target_company.id))
if model_name == 'account.account':
domain.append(('code', '=', record.code))
elif model_name == 'account.journal':
domain.append(('code', '=', record.code))
elif model_name == 'stock.location':
match = self.env['stock.location'].search([
('company_id', '=', target_company.id),
('complete_name', '=', record.complete_name)
], limit=1)
if match:
return match
domain.append(('name', '=', record.name))
elif model_name == 'account.tax':
domain.extend([
('name', '=', record.name),
('type_tax_use', '=', record.type_tax_use),
('amount', '=', record.amount),
('amount_type', '=', record.amount_type)
])
elif model_name == 'stock.route':
domain.append(('name', '=', record.name))
else:
if 'name' in record._fields:
domain.append(('name', '=', record.name))
else:
return record
match = self.env[model_name].search(domain, limit=1)
# Universal Fallback: return the original record if no company-specific counterpart exists
if not match:
return record
return match
def _sync_to_branch_companies(self):
"""Replicate category settings to all active branch companies recursively."""
if self.env.context.get('skip_company_dependent_sync'):
return
current_company = self.env.company
# Find all descendant/child companies (excluding active/parent company itself)
branch_companies = self.env['res.company'].search([('id', 'child_of', current_company.id)]) - current_company
if not branch_companies:
return
for category in self:
for branch in branch_companies:
vals_to_write = {
'property_valuation': category.with_company(current_company).property_valuation,
'property_cost_method': category.with_company(current_company).property_cost_method,
}
# Fields to sync: Income, Expense, Valuation, Production Cost, Price Difference
m2o_fields = [
'property_account_income_categ_id',
'property_account_expense_categ_id',
'property_price_difference_account_id',
'property_stock_account_production_cost_id',
'property_stock_valuation_account_id'
]
for field in m2o_fields:
val = category.with_company(current_company)[field]
if val:
counterpart = category._get_counterpart_record(val, branch)
vals_to_write[field] = counterpart.id if counterpart else False
else:
vals_to_write[field] = False
# Write to the branch company context, suppressing recursion via skip_company_dependent_sync
category.with_company(branch).with_context(skip_company_dependent_sync=True).write(vals_to_write)
@api.model_create_multi
def create(self, vals_list):
categories = super().create(vals_list)
categories._sync_to_branch_companies()
return categories
def write(self, vals):
res = super().write(vals)
sync_fields = [
'property_valuation',
'property_cost_method',
'property_account_income_categ_id',
'property_account_expense_categ_id',
'property_price_difference_account_id',
'property_stock_account_production_cost_id',
'property_stock_valuation_account_id'
]
if any(f in vals for f in sync_fields):
self._sync_to_branch_companies()
return res

147
models/product_template.py Normal file
View File

@ -0,0 +1,147 @@
# -*- coding: utf-8 -*-
from odoo import api, fields, models
class ProductTemplate(models.Model):
_inherit = 'product.template'
def _get_counterpart_record(self, record, target_company):
"""Helper to find counterpart record in target company, fallback to parent if not found."""
if not record:
return record.browse()
has_company_ids = 'company_ids' in record._fields
has_company_id = 'company_id' in record._fields
if has_company_ids and target_company in record.company_ids:
return record
if has_company_id and record.company_id and record.company_id.id == target_company.id:
return record
if not has_company_ids and not has_company_id:
return record
if has_company_id and not record.company_id:
return record
model_name = record._name
domain = []
if has_company_ids:
domain.append(('company_ids', 'in', [target_company.id]))
else:
domain.append(('company_id', '=', target_company.id))
if model_name == 'account.account':
domain.append(('code', '=', record.code))
elif model_name == 'account.journal':
domain.append(('code', '=', record.code))
elif model_name == 'stock.location':
match = self.env['stock.location'].search([
('company_id', '=', target_company.id),
('complete_name', '=', record.complete_name)
], limit=1)
if match:
return match
domain.append(('name', '=', record.name))
elif model_name == 'account.tax':
domain.extend([
('name', '=', record.name),
('type_tax_use', '=', record.type_tax_use),
('amount', '=', record.amount),
('amount_type', '=', record.amount_type)
])
elif model_name == 'stock.route':
domain.append(('name', '=', record.name))
else:
if 'name' in record._fields:
domain.append(('name', '=', record.name))
else:
return record
match = self.env[model_name].search(domain, limit=1)
if not match:
return record
return match
def _sync_to_branch_companies(self, sync_taxes=True, sync_m2o=True):
"""Replicate product template settings to all active branch companies recursively."""
if self.env.context.get('skip_company_dependent_sync'):
return
current_company = self.env.company
branch_companies = self.env['res.company'].search([('id', 'child_of', current_company.id)]) - current_company
if not branch_companies:
return
for product in self:
for branch in branch_companies:
# 1. Sync company-dependent many2one fields (accounts, locations)
if sync_m2o:
vals_to_write = {}
m2o_fields = [
'property_account_income_id',
'property_account_expense_id',
'property_price_difference_account_id',
'property_stock_inventory',
'property_stock_production'
]
for field in m2o_fields:
val = product.with_company(current_company)[field]
if val:
counterpart = product._get_counterpart_record(val, branch)
vals_to_write[field] = counterpart.id if counterpart else False
else:
vals_to_write[field] = False
if vals_to_write:
product.with_company(branch).with_context(skip_company_dependent_sync=True).write(vals_to_write)
# 2. Sync many2many fields (taxes, routes) safely preserving other companies' values
if sync_taxes:
m2m_fields = ['taxes_id', 'supplier_taxes_id', 'route_ids']
for field in m2m_fields:
all_records = product[field]
# Filter active/parent company records
current_records = all_records.filtered(
lambda r: 'company_id' in r._fields and r.company_id == current_company
)
# Filter target branch company records
branch_records = all_records.filtered(
lambda r: 'company_id' in r._fields and r.company_id == branch
)
# Filter records belonging to other companies
other_records = all_records - current_records - branch_records
counterparts = self.env[all_records._name]
for r in current_records:
match = product._get_counterpart_record(r, branch)
if match:
counterparts |= match
final_records = other_records | current_records | counterparts
product.with_context(skip_company_dependent_sync=True).write({
field: [(6, 0, final_records.ids)]
})
@api.model_create_multi
def create(self, vals_list):
products = super().create(vals_list)
products._sync_to_branch_companies()
return products
def write(self, vals):
res = super().write(vals)
m2o_fields = [
'property_account_income_id',
'property_account_expense_id',
'property_price_difference_account_id',
'property_stock_inventory',
'property_stock_production'
]
m2m_fields = ['taxes_id', 'supplier_taxes_id', 'route_ids']
sync_taxes = any(f in vals for f in m2m_fields)
sync_m2o = any(f in vals for f in m2o_fields)
if sync_taxes or sync_m2o:
self._sync_to_branch_companies(sync_taxes=sync_taxes, sync_m2o=sync_m2o)
return res