From 97a1b6397750053646a31205e8694a699b5a7dad Mon Sep 17 00:00:00 2001 From: Suherdy Yacob Date: Mon, 25 May 2026 08:42:22 +0700 Subject: [PATCH] first commit --- .gitignore | 140 +++++++++++++++++++++++++++++++++++ README.md | 74 +++++++++++++++++++ __init__.py | 2 + __manifest__.py | 32 ++++++++ models/__init__.py | 3 + models/product_category.py | 122 ++++++++++++++++++++++++++++++ models/product_template.py | 147 +++++++++++++++++++++++++++++++++++++ 7 files changed, 520 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 __init__.py create mode 100644 __manifest__.py create mode 100644 models/__init__.py create mode 100644 models/product_category.py create mode 100644 models/product_template.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f645940 --- /dev/null +++ b/.gitignore @@ -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 diff --git a/README.md b/README.md new file mode 100644 index 0000000..a5c175a --- /dev/null +++ b/README.md @@ -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 +``` diff --git a/__init__.py b/__init__.py new file mode 100644 index 0000000..a0fdc10 --- /dev/null +++ b/__init__.py @@ -0,0 +1,2 @@ +# -*- coding: utf-8 -*- +from . import models diff --git a/__manifest__.py b/__manifest__.py new file mode 100644 index 0000000..c33fb62 --- /dev/null +++ b/__manifest__.py @@ -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', +} diff --git a/models/__init__.py b/models/__init__.py new file mode 100644 index 0000000..9aae387 --- /dev/null +++ b/models/__init__.py @@ -0,0 +1,3 @@ +# -*- coding: utf-8 -*- +from . import product_category +from . import product_template diff --git a/models/product_category.py b/models/product_category.py new file mode 100644 index 0000000..e7de36e --- /dev/null +++ b/models/product_category.py @@ -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 diff --git a/models/product_template.py b/models/product_template.py new file mode 100644 index 0000000..ee9977d --- /dev/null +++ b/models/product_template.py @@ -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