feat: add recursive synchronization for Bill of Materials (BOM) across multi-company hierarchies
This commit is contained in:
parent
97a1b63977
commit
0cad3d99c9
23
README.md
23
README.md
@ -1,14 +1,15 @@
|
|||||||
# Multi-Company Product & Category Settings Sync
|
# Multi-Company Product, Category & BOM 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.
|
This Odoo 19 module automatically shares and synchronizes company-dependent product, product category, and Bill of Materials (BOM / recipe) settings from a parent company to all active branch (descendant) companies recursively.
|
||||||
|
|
||||||
## Overview
|
## 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:
|
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, location routing, and manufacturing recipes remain company-dependent:
|
||||||
1. **Accounting Accounts**: Stored as `jsonb` fields on the model in Odoo 19, mapping company IDs directly to account record IDs.
|
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.
|
2. **Taxes & Routes**: Many-to-many relationships where each company must map its own specific records.
|
||||||
|
3. **Bill of Materials (BOM)**: Explicitly linked to a company, requiring child branches to duplicate manufacturing structures if they wish to produce locally.
|
||||||
|
|
||||||
This module automates the propagation of these settings down the company hierarchy, minimizing manual configuration errors across multiple branch companies.
|
This module automates the propagation of these settings and structures down the company hierarchy, minimizing manual configuration errors across multiple branch companies.
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
@ -39,13 +40,19 @@ Synchronizes the following fields from the parent product template to branch tem
|
|||||||
- **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.
|
- **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.
|
- **Cost Price Excluded**: Cost price (`standard_price`) is excluded from synchronization to allow branch-specific costing.
|
||||||
|
|
||||||
|
### 🔨 Bill of Materials (BOM) Sync (`mrp.bom`)
|
||||||
|
Synchronizes manufacturing recipes and component lines recursively:
|
||||||
|
- **Recipe Structures**: Replicates `mrp.bom` records and their ingredient lines (`mrp.bom.line`) to branch companies.
|
||||||
|
- **Parent Tracking**: Automatically maps and tracks branch records to their parent via a custom field (`parent_bom_id`) complete with cascade deletion.
|
||||||
|
- **Hierarchical Company Matching**: Implements custom compatibility matching that respects Odoo's multi-company visibility. BOMs are replicated if the main product template and all ingredient component products are either global or belong to a parent/ancestor company of the branch (e.g. products and ingredients owned by the parent company `OT` can be safely used inside BOM records created for the child branch `Mie Mapan Barata`). This strictly satisfies Odoo's internal `_check_company` constraints while maintaining centralized recipe ownership.
|
||||||
|
|
||||||
### 🔄 Intelligent Branch Counterpart Matching
|
### 🔄 Intelligent Branch Counterpart Matching
|
||||||
When mapping a parent company record to a child company:
|
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).
|
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`.
|
2. **Taxes**: Matches by tax `name`, `type_tax_use`, `amount`, and `amount_type`.
|
||||||
3. **Routes**: Matches by route `name`.
|
3. **Routes**: Matches by route `name`.
|
||||||
4. **Locations**: Matches by `complete_name` first, then by `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).
|
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 setups where branch companies share the parent company's chart of accounts and taxes).
|
||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
@ -60,6 +67,9 @@ When mapping a parent company record to a child company:
|
|||||||
### Infinite Loop Prevention
|
### 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.
|
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.
|
||||||
|
|
||||||
|
### Context-Independent Parent Matching
|
||||||
|
To ensure correct execution inside background crons, UI switches, and CLI shell scripts, the parent/source company is derived dynamically from each record's own `company_id` field instead of defaulting to `self.env.company`.
|
||||||
|
|
||||||
### Directory Structure
|
### Directory Structure
|
||||||
```
|
```
|
||||||
multicompany_product_sync/
|
multicompany_product_sync/
|
||||||
@ -70,5 +80,6 @@ multicompany_product_sync/
|
|||||||
└── models/
|
└── models/
|
||||||
├── __init__.py
|
├── __init__.py
|
||||||
├── product_category.py
|
├── product_category.py
|
||||||
└── product_template.py
|
├── product_template.py
|
||||||
|
└── mrp_bom.py
|
||||||
```
|
```
|
||||||
|
|||||||
@ -16,6 +16,8 @@ Synchronizes the following settings from a parent company to all active branch c
|
|||||||
- Customers and Vendors taxes (mapped by name and amount)
|
- Customers and Vendors taxes (mapped by name and amount)
|
||||||
- Routes (mapped by name)
|
- Routes (mapped by name)
|
||||||
- Stock Inventory and Production locations (mapped by path)
|
- Stock Inventory and Production locations (mapped by path)
|
||||||
|
- Bill of Materials (BOM):
|
||||||
|
- Automatically replicates BOM recipe structures and ingredient component lines for compatible shared products.
|
||||||
""",
|
""",
|
||||||
'author': 'Suherdy Yacob',
|
'author': 'Suherdy Yacob',
|
||||||
'category': 'Sales/Sales',
|
'category': 'Sales/Sales',
|
||||||
@ -23,6 +25,7 @@ Synchronizes the following settings from a parent company to all active branch c
|
|||||||
'product',
|
'product',
|
||||||
'account',
|
'account',
|
||||||
'stock',
|
'stock',
|
||||||
|
'mrp',
|
||||||
],
|
],
|
||||||
'data': [],
|
'data': [],
|
||||||
'installable': True,
|
'installable': True,
|
||||||
|
|||||||
@ -1,3 +1,5 @@
|
|||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
from . import product_category
|
from . import product_category
|
||||||
from . import product_template
|
from . import product_template
|
||||||
|
from . import mrp_bom
|
||||||
|
|
||||||
|
|||||||
128
models/mrp_bom.py
Normal file
128
models/mrp_bom.py
Normal file
@ -0,0 +1,128 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
from odoo import api, fields, models
|
||||||
|
|
||||||
|
class MrpBom(models.Model):
|
||||||
|
_inherit = 'mrp.bom'
|
||||||
|
|
||||||
|
# Tracks the parent BOM record that this branch BOM is synchronized from
|
||||||
|
parent_bom_id = fields.Many2one(
|
||||||
|
'mrp.bom',
|
||||||
|
string='Parent BOM',
|
||||||
|
ondelete='cascade',
|
||||||
|
index=True,
|
||||||
|
copy=False
|
||||||
|
)
|
||||||
|
|
||||||
|
def _is_compatible_with_company(self, company):
|
||||||
|
"""
|
||||||
|
Check if the BOM and all of its components are compatible with the target company.
|
||||||
|
A product is compatible with a target company if:
|
||||||
|
1. It is global (no company_id set).
|
||||||
|
2. It belongs to the target company.
|
||||||
|
3. The target company is a descendant (child) of the product's company (i.e. parent company's product).
|
||||||
|
"""
|
||||||
|
def is_product_compatible(product):
|
||||||
|
if not product.company_id:
|
||||||
|
return True
|
||||||
|
# Check if the target company is the same or a descendant of the product's company
|
||||||
|
child_companies = self.env['res.company'].search([('id', 'child_of', product.company_id.id)])
|
||||||
|
return company.id in child_companies.ids
|
||||||
|
|
||||||
|
# The main product template must be compatible
|
||||||
|
if not is_product_compatible(self.product_tmpl_id):
|
||||||
|
return False
|
||||||
|
|
||||||
|
# The product variant must be compatible
|
||||||
|
if self.product_id and not is_product_compatible(self.product_id):
|
||||||
|
return False
|
||||||
|
|
||||||
|
# All component products must be compatible
|
||||||
|
for line in self.bom_line_ids:
|
||||||
|
if not is_product_compatible(line.product_id):
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
def _sync_to_branch_companies(self):
|
||||||
|
"""Replicate Bill of Materials (BOM) to all active branch companies recursively."""
|
||||||
|
if self.env.context.get('skip_company_dependent_sync'):
|
||||||
|
return
|
||||||
|
|
||||||
|
for bom in self:
|
||||||
|
# Prevent synchronization loop if this is already a child BOM
|
||||||
|
if bom.parent_bom_id:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Use the BOM's company as the parent company
|
||||||
|
parent_company = bom.company_id
|
||||||
|
if not parent_company:
|
||||||
|
continue
|
||||||
|
|
||||||
|
branch_companies = self.env['res.company'].search([('id', 'child_of', parent_company.id)]) - parent_company
|
||||||
|
if not branch_companies:
|
||||||
|
continue
|
||||||
|
|
||||||
|
for branch in branch_companies:
|
||||||
|
# Check compatibility before creating/updating branch BOM
|
||||||
|
if not bom._is_compatible_with_company(branch):
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Search if a branch BOM already exists for this parent BOM
|
||||||
|
child_bom = self.env['mrp.bom'].with_company(branch).search([
|
||||||
|
('parent_bom_id', '=', bom.id),
|
||||||
|
('company_id', '=', branch.id)
|
||||||
|
], limit=1)
|
||||||
|
|
||||||
|
bom_vals = {
|
||||||
|
'product_tmpl_id': bom.product_tmpl_id.id,
|
||||||
|
'product_id': bom.product_id.id if bom.product_id else False,
|
||||||
|
'product_qty': bom.product_qty,
|
||||||
|
'product_uom_id': bom.product_uom_id.id,
|
||||||
|
'type': bom.type,
|
||||||
|
'code': bom.code,
|
||||||
|
'consumption': bom.consumption,
|
||||||
|
'active': bom.active,
|
||||||
|
'company_id': branch.id,
|
||||||
|
'parent_bom_id': bom.id,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Prep line values
|
||||||
|
line_vals_list = []
|
||||||
|
for line in bom.bom_line_ids:
|
||||||
|
line_vals_list.append((0, 0, {
|
||||||
|
'product_id': line.product_id.id,
|
||||||
|
'product_qty': line.product_qty,
|
||||||
|
'product_uom_id': line.product_uom_id.id,
|
||||||
|
'sequence': line.sequence,
|
||||||
|
'company_id': branch.id,
|
||||||
|
}))
|
||||||
|
|
||||||
|
if child_bom:
|
||||||
|
# Update existing child BOM
|
||||||
|
# 1. Clear existing lines
|
||||||
|
child_bom.with_context(skip_company_dependent_sync=True).write({
|
||||||
|
'bom_line_ids': [(5, 0, 0)]
|
||||||
|
})
|
||||||
|
# 2. Re-create settings and lines
|
||||||
|
bom_vals['bom_line_ids'] = line_vals_list
|
||||||
|
child_bom.with_context(skip_company_dependent_sync=True).write(bom_vals)
|
||||||
|
else:
|
||||||
|
# Create new child BOM
|
||||||
|
bom_vals['bom_line_ids'] = line_vals_list
|
||||||
|
self.env['mrp.bom'].with_company(branch).with_context(skip_company_dependent_sync=True).create(bom_vals)
|
||||||
|
|
||||||
|
@api.model_create_multi
|
||||||
|
def create(self, vals_list):
|
||||||
|
boms = super().create(vals_list)
|
||||||
|
boms._sync_to_branch_companies()
|
||||||
|
return boms
|
||||||
|
|
||||||
|
def write(self, vals):
|
||||||
|
res = super().write(vals)
|
||||||
|
# Re-sync if relevant BOM or line fields are changed
|
||||||
|
sync_fields = [
|
||||||
|
'product_tmpl_id', 'product_id', 'product_qty', 'product_uom_id',
|
||||||
|
'type', 'code', 'consumption', 'active', 'bom_line_ids'
|
||||||
|
]
|
||||||
|
if any(f in vals for f in sync_fields):
|
||||||
|
self._sync_to_branch_companies()
|
||||||
|
return res
|
||||||
@ -65,12 +65,12 @@ class ProductTemplate(models.Model):
|
|||||||
if self.env.context.get('skip_company_dependent_sync'):
|
if self.env.context.get('skip_company_dependent_sync'):
|
||||||
return
|
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 product in self:
|
||||||
|
parent_company = product.company_id or self.env.company
|
||||||
|
branch_companies = self.env['res.company'].search([('id', 'child_of', parent_company.id)]) - parent_company
|
||||||
|
if not branch_companies:
|
||||||
|
continue
|
||||||
|
|
||||||
for branch in branch_companies:
|
for branch in branch_companies:
|
||||||
# 1. Sync company-dependent many2one fields (accounts, locations)
|
# 1. Sync company-dependent many2one fields (accounts, locations)
|
||||||
if sync_m2o:
|
if sync_m2o:
|
||||||
@ -83,7 +83,7 @@ class ProductTemplate(models.Model):
|
|||||||
'property_stock_production'
|
'property_stock_production'
|
||||||
]
|
]
|
||||||
for field in m2o_fields:
|
for field in m2o_fields:
|
||||||
val = product.with_company(current_company)[field]
|
val = product.with_company(parent_company)[field]
|
||||||
if val:
|
if val:
|
||||||
counterpart = product._get_counterpart_record(val, branch)
|
counterpart = product._get_counterpart_record(val, branch)
|
||||||
vals_to_write[field] = counterpart.id if counterpart else False
|
vals_to_write[field] = counterpart.id if counterpart else False
|
||||||
@ -101,7 +101,7 @@ class ProductTemplate(models.Model):
|
|||||||
|
|
||||||
# Filter active/parent company records
|
# Filter active/parent company records
|
||||||
current_records = all_records.filtered(
|
current_records = all_records.filtered(
|
||||||
lambda r: 'company_id' in r._fields and r.company_id == current_company
|
lambda r: 'company_id' in r._fields and r.company_id == parent_company
|
||||||
)
|
)
|
||||||
# Filter target branch company records
|
# Filter target branch company records
|
||||||
branch_records = all_records.filtered(
|
branch_records = all_records.filtered(
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user