Initial commit
This commit is contained in:
6
fusion_inventory_sync/models/__init__.py
Normal file
6
fusion_inventory_sync/models/__init__.py
Normal file
@@ -0,0 +1,6 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from . import sync_config
|
||||
from . import product_sync_mapping
|
||||
from . import product_template
|
||||
from . import stock_move
|
||||
from . import sync_log
|
||||
38
fusion_inventory_sync/models/product_sync_mapping.py
Normal file
38
fusion_inventory_sync/models/product_sync_mapping.py
Normal file
@@ -0,0 +1,38 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from odoo import models, fields
|
||||
|
||||
|
||||
class FusionProductSyncMapping(models.Model):
|
||||
"""Maps local products to remote products for inventory sync."""
|
||||
_name = 'fusion.product.sync.mapping'
|
||||
_description = 'Product Sync Mapping'
|
||||
_rec_name = 'remote_product_name'
|
||||
_order = 'remote_product_name'
|
||||
|
||||
config_id = fields.Many2one('fusion.sync.config', string='Sync Config',
|
||||
required=True, ondelete='cascade')
|
||||
# Local product link
|
||||
local_product_id = fields.Many2one('product.template', string='Local Product',
|
||||
help='The matching product in this Odoo instance')
|
||||
auto_matched = fields.Boolean(string='Auto-Matched',
|
||||
help='True if the product was automatically matched by SKU or name')
|
||||
|
||||
# Remote product info
|
||||
remote_product_id = fields.Integer(string='Remote Product ID', index=True)
|
||||
remote_product_name = fields.Char(string='Remote Product Name')
|
||||
remote_default_code = fields.Char(string='Remote SKU/Reference')
|
||||
remote_list_price = fields.Float(string='Remote Price')
|
||||
remote_category = fields.Char(string='Remote Category')
|
||||
|
||||
# Remote stock levels (updated by sync)
|
||||
remote_qty_available = fields.Float(string='Remote On Hand', readonly=True,
|
||||
help='Quantity currently on hand at the remote location')
|
||||
remote_qty_forecast = fields.Float(string='Remote Forecast', readonly=True,
|
||||
help='Forecasted quantity (on hand - outgoing + incoming)')
|
||||
last_stock_sync = fields.Datetime(string='Stock Last Updated', readonly=True)
|
||||
|
||||
_sql_constraints = [
|
||||
('unique_remote_product',
|
||||
'UNIQUE(config_id, remote_product_id)',
|
||||
'Each remote product can only be mapped once per sync configuration.'),
|
||||
]
|
||||
33
fusion_inventory_sync/models/product_template.py
Normal file
33
fusion_inventory_sync/models/product_template.py
Normal file
@@ -0,0 +1,33 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from odoo import models, fields, api
|
||||
|
||||
|
||||
class ProductTemplate(models.Model):
|
||||
"""Extend product template with remote inventory visibility."""
|
||||
_inherit = 'product.template'
|
||||
|
||||
sync_mapping_ids = fields.One2many('fusion.product.sync.mapping', 'local_product_id',
|
||||
string='Remote Inventory Links')
|
||||
remote_qty_available = fields.Float(
|
||||
string='Remote On Hand',
|
||||
compute='_compute_remote_stock',
|
||||
store=False,
|
||||
help='Total on-hand quantity at remote locations (Mobility Specialties)')
|
||||
remote_qty_forecast = fields.Float(
|
||||
string='Remote Forecast',
|
||||
compute='_compute_remote_stock',
|
||||
store=False,
|
||||
help='Total forecasted quantity at remote locations')
|
||||
has_remote_mapping = fields.Boolean(
|
||||
string='Has Remote Link',
|
||||
compute='_compute_remote_stock',
|
||||
store=False)
|
||||
|
||||
@api.depends('sync_mapping_ids', 'sync_mapping_ids.remote_qty_available',
|
||||
'sync_mapping_ids.remote_qty_forecast')
|
||||
def _compute_remote_stock(self):
|
||||
for product in self:
|
||||
mappings = product.sync_mapping_ids
|
||||
product.remote_qty_available = sum(mappings.mapped('remote_qty_available'))
|
||||
product.remote_qty_forecast = sum(mappings.mapped('remote_qty_forecast'))
|
||||
product.has_remote_mapping = bool(mappings)
|
||||
116
fusion_inventory_sync/models/stock_move.py
Normal file
116
fusion_inventory_sync/models/stock_move.py
Normal file
@@ -0,0 +1,116 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
import logging
|
||||
from odoo import models, fields
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class StockMove(models.Model):
|
||||
"""Extend stock moves to push changes to remote Odoo instance."""
|
||||
_inherit = 'stock.move'
|
||||
|
||||
def _action_done(self, cancel_backorder=False):
|
||||
"""Override to trigger remote sync after stock moves complete."""
|
||||
res = super()._action_done(cancel_backorder=cancel_backorder)
|
||||
|
||||
# After moves are done, queue a remote stock push for affected products
|
||||
try:
|
||||
self._push_stock_to_remote()
|
||||
except Exception as e:
|
||||
# Never block local operations due to sync failure
|
||||
_logger.warning(f'Remote stock push failed (non-blocking): {e}')
|
||||
|
||||
return res
|
||||
|
||||
def _push_stock_to_remote(self):
|
||||
"""Push stock level changes to the remote Odoo instance.
|
||||
|
||||
Only pushes for products that have a sync mapping.
|
||||
Runs async-safe: failures don't block local operations.
|
||||
"""
|
||||
if not self:
|
||||
return
|
||||
|
||||
# Get unique product templates from completed moves
|
||||
product_tmpls = self.mapped('product_id.product_tmpl_id')
|
||||
if not product_tmpls:
|
||||
return
|
||||
|
||||
# Find sync mappings for these products
|
||||
Mapping = self.env['fusion.product.sync.mapping']
|
||||
mappings = Mapping.search([
|
||||
('local_product_id', 'in', product_tmpls.ids),
|
||||
('config_id.active', '=', True),
|
||||
('config_id.state', '=', 'connected'),
|
||||
('remote_product_id', '!=', 0),
|
||||
])
|
||||
|
||||
if not mappings:
|
||||
return
|
||||
|
||||
# Group by config for efficient batch push
|
||||
configs = {}
|
||||
for mapping in mappings:
|
||||
config = mapping.config_id
|
||||
if config.id not in configs:
|
||||
configs[config.id] = {
|
||||
'config': config,
|
||||
'mappings': self.env['fusion.product.sync.mapping'],
|
||||
}
|
||||
configs[config.id]['mappings'] |= mapping
|
||||
|
||||
for config_data in configs.values():
|
||||
config = config_data['config']
|
||||
config_mappings = config_data['mappings']
|
||||
try:
|
||||
self._push_stock_levels(config, config_mappings)
|
||||
except Exception as e:
|
||||
_logger.warning(
|
||||
f'Failed to push stock to {config.name}: {e}'
|
||||
)
|
||||
|
||||
def _push_stock_levels(self, config, mappings):
|
||||
"""Push current local stock levels to the remote instance.
|
||||
|
||||
This updates the remote side with our current on-hand qty
|
||||
so the remote instance knows what we have available.
|
||||
"""
|
||||
uid, models_proxy = config._get_xmlrpc_connection()
|
||||
|
||||
for mapping in mappings:
|
||||
local_product = mapping.local_product_id
|
||||
if not local_product:
|
||||
continue
|
||||
|
||||
# Get current local stock for this product
|
||||
local_qty = local_product.qty_available
|
||||
local_forecast = local_product.virtual_available
|
||||
|
||||
# Update the mapping record with current local stock
|
||||
mapping.write({
|
||||
'last_stock_sync': fields.Datetime.now(),
|
||||
})
|
||||
|
||||
# Log the push for debugging
|
||||
_logger.info(
|
||||
f'Stock push: {local_product.name} -> {config.name} '
|
||||
f'(local_qty={local_qty}, remote_id={mapping.remote_product_id})'
|
||||
)
|
||||
|
||||
# Optionally update a custom field on the remote side
|
||||
# This writes to a field on the remote product to track
|
||||
# what the partner store has available
|
||||
try:
|
||||
models_proxy.execute_kw(
|
||||
config.db_name, uid, config.api_key,
|
||||
'product.template', 'write',
|
||||
[[mapping.remote_product_id], {
|
||||
'x_partner_qty_available': local_qty,
|
||||
}]
|
||||
)
|
||||
except Exception as e:
|
||||
# If the remote field doesn't exist yet, just log it
|
||||
_logger.debug(
|
||||
f'Could not update remote field x_partner_qty_available: {e}. '
|
||||
f'Create this field on the remote instance for full bi-directional sync.'
|
||||
)
|
||||
257
fusion_inventory_sync/models/sync_config.py
Normal file
257
fusion_inventory_sync/models/sync_config.py
Normal file
@@ -0,0 +1,257 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
import logging
|
||||
import xmlrpc.client
|
||||
from odoo import models, fields, api
|
||||
from odoo.exceptions import UserError
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class FusionSyncConfig(models.Model):
|
||||
"""Configuration for remote Odoo instance connection."""
|
||||
_name = 'fusion.sync.config'
|
||||
_description = 'Inventory Sync Configuration'
|
||||
_rec_name = 'name'
|
||||
|
||||
name = fields.Char(string='Connection Name', required=True)
|
||||
url = fields.Char(string='Remote URL', required=True,
|
||||
help='Full URL of the remote Odoo instance (e.g., https://erp.mobilityspecialties.com)')
|
||||
db_name = fields.Char(string='Database Name', required=True,
|
||||
help='Name of the remote database')
|
||||
username = fields.Char(string='Username', required=True,
|
||||
help='Login username for the remote instance')
|
||||
api_key = fields.Char(string='API Key / Password', required=True,
|
||||
help='API key or password for authentication')
|
||||
active = fields.Boolean(default=True)
|
||||
state = fields.Selection([
|
||||
('draft', 'Not Connected'),
|
||||
('connected', 'Connected'),
|
||||
('error', 'Connection Error'),
|
||||
], string='Status', default='draft', readonly=True)
|
||||
last_sync = fields.Datetime(string='Last Sync', readonly=True)
|
||||
last_sync_status = fields.Text(string='Last Sync Result', readonly=True)
|
||||
sync_interval = fields.Integer(string='Sync Interval (minutes)', default=30,
|
||||
help='How often to run the automatic sync')
|
||||
remote_uid = fields.Integer(string='Remote User ID', readonly=True)
|
||||
company_id = fields.Many2one('res.company', string='Company',
|
||||
default=lambda self: self.env.company)
|
||||
|
||||
# Sync scope
|
||||
sync_products = fields.Boolean(string='Sync Products', default=True)
|
||||
sync_stock = fields.Boolean(string='Sync Stock Levels', default=True)
|
||||
remote_warehouse_name = fields.Char(string='Remote Warehouse Name',
|
||||
help='Name of the warehouse on the remote instance to read stock from. Leave empty for all.')
|
||||
|
||||
def _get_xmlrpc_connection(self):
|
||||
"""Establish XML-RPC connection to the remote Odoo instance."""
|
||||
self.ensure_one()
|
||||
url = self.url.rstrip('/')
|
||||
try:
|
||||
common = xmlrpc.client.ServerProxy(f'{url}/xmlrpc/2/common', allow_none=True)
|
||||
uid = common.authenticate(self.db_name, self.username, self.api_key, {})
|
||||
if not uid:
|
||||
raise UserError('Authentication failed. Check username/API key.')
|
||||
models_proxy = xmlrpc.client.ServerProxy(f'{url}/xmlrpc/2/object', allow_none=True)
|
||||
return uid, models_proxy
|
||||
except xmlrpc.client.Fault as e:
|
||||
raise UserError(f'XML-RPC error: {e.faultString}')
|
||||
except Exception as e:
|
||||
raise UserError(f'Connection error: {str(e)}')
|
||||
|
||||
def action_test_connection(self):
|
||||
"""Test the connection to the remote Odoo instance."""
|
||||
self.ensure_one()
|
||||
try:
|
||||
uid, models_proxy = self._get_xmlrpc_connection()
|
||||
# Test by reading the server version
|
||||
version_info = xmlrpc.client.ServerProxy(
|
||||
f'{self.url.rstrip("/")}/xmlrpc/2/common', allow_none=True
|
||||
).version()
|
||||
self.write({
|
||||
'state': 'connected',
|
||||
'remote_uid': uid,
|
||||
'last_sync_status': f'Connection successful. Remote server: {version_info.get("server_serie", "unknown")}',
|
||||
})
|
||||
return {
|
||||
'type': 'ir.actions.client',
|
||||
'tag': 'display_notification',
|
||||
'params': {
|
||||
'title': 'Connection Successful',
|
||||
'message': f'Connected to {self.url} as user ID {uid}',
|
||||
'type': 'success',
|
||||
'sticky': False,
|
||||
},
|
||||
}
|
||||
except Exception as e:
|
||||
self.write({
|
||||
'state': 'error',
|
||||
'last_sync_status': f'Connection failed: {str(e)}',
|
||||
})
|
||||
raise
|
||||
|
||||
def action_sync_now(self):
|
||||
"""Manually trigger a full sync."""
|
||||
self.ensure_one()
|
||||
self._run_sync()
|
||||
return {
|
||||
'type': 'ir.actions.client',
|
||||
'tag': 'display_notification',
|
||||
'params': {
|
||||
'title': 'Sync Complete',
|
||||
'message': self.last_sync_status,
|
||||
'type': 'success',
|
||||
'sticky': False,
|
||||
},
|
||||
}
|
||||
|
||||
def _run_sync(self):
|
||||
"""Execute the sync process."""
|
||||
self.ensure_one()
|
||||
try:
|
||||
uid, models_proxy = self._get_xmlrpc_connection()
|
||||
results = []
|
||||
|
||||
if self.sync_products:
|
||||
count = self._sync_products(uid, models_proxy)
|
||||
results.append(f'{count} products synced')
|
||||
|
||||
if self.sync_stock:
|
||||
count = self._sync_stock_levels(uid, models_proxy)
|
||||
results.append(f'{count} stock levels updated')
|
||||
|
||||
status = ' | '.join(results) if results else 'Nothing to sync'
|
||||
self.write({
|
||||
'state': 'connected',
|
||||
'last_sync': fields.Datetime.now(),
|
||||
'last_sync_status': status,
|
||||
})
|
||||
|
||||
# Log the sync operation
|
||||
self.env['fusion.sync.log'].create({
|
||||
'config_id': self.id,
|
||||
'direction': 'pull',
|
||||
'sync_type': 'full',
|
||||
'status': 'success',
|
||||
'summary': status,
|
||||
'product_count': sum(1 for r in results if 'product' in r.lower()),
|
||||
})
|
||||
_logger.info(f'Inventory sync complete: {status}')
|
||||
|
||||
except Exception as e:
|
||||
error_msg = f'Sync failed: {str(e)}'
|
||||
self.write({
|
||||
'state': 'error',
|
||||
'last_sync': fields.Datetime.now(),
|
||||
'last_sync_status': error_msg,
|
||||
})
|
||||
_logger.error(error_msg)
|
||||
|
||||
def _sync_products(self, uid, models_proxy):
|
||||
"""Sync product catalog from remote instance."""
|
||||
self.ensure_one()
|
||||
Mapping = self.env['fusion.product.sync.mapping']
|
||||
|
||||
# Read remote products (only storable/consumable, with internal reference)
|
||||
remote_products = models_proxy.execute_kw(
|
||||
self.db_name, uid, self.api_key,
|
||||
'product.template', 'search_read',
|
||||
[[('type', 'in', ['consu', 'product'])]],
|
||||
{'fields': ['id', 'name', 'default_code', 'list_price', 'type', 'categ_id'],
|
||||
'limit': 5000}
|
||||
)
|
||||
|
||||
synced = 0
|
||||
for rp in remote_products:
|
||||
# Try to find existing mapping
|
||||
mapping = Mapping.search([
|
||||
('config_id', '=', self.id),
|
||||
('remote_product_id', '=', rp['id']),
|
||||
], limit=1)
|
||||
|
||||
vals = {
|
||||
'config_id': self.id,
|
||||
'remote_product_id': rp['id'],
|
||||
'remote_product_name': rp.get('name', ''),
|
||||
'remote_default_code': rp.get('default_code', '') or '',
|
||||
'remote_list_price': rp.get('list_price', 0),
|
||||
'remote_category': rp.get('categ_id', [False, ''])[1] if rp.get('categ_id') else '',
|
||||
}
|
||||
|
||||
if mapping:
|
||||
mapping.write(vals)
|
||||
else:
|
||||
# Try auto-match by internal reference (SKU)
|
||||
local_product = False
|
||||
if rp.get('default_code'):
|
||||
local_product = self.env['product.template'].search([
|
||||
('default_code', '=', rp['default_code'])
|
||||
], limit=1)
|
||||
# Fallback: try match by exact name
|
||||
if not local_product and rp.get('name'):
|
||||
local_product = self.env['product.template'].search([
|
||||
('name', 'ilike', rp['name'])
|
||||
], limit=1)
|
||||
|
||||
vals['local_product_id'] = local_product.id if local_product else False
|
||||
vals['auto_matched'] = bool(local_product)
|
||||
Mapping.create(vals)
|
||||
|
||||
synced += 1
|
||||
|
||||
return synced
|
||||
|
||||
def _sync_stock_levels(self, uid, models_proxy):
|
||||
"""Sync stock quantities from remote instance."""
|
||||
self.ensure_one()
|
||||
Mapping = self.env['fusion.product.sync.mapping']
|
||||
|
||||
# Get all mappings with remote product IDs
|
||||
mappings = Mapping.search([
|
||||
('config_id', '=', self.id),
|
||||
('remote_product_id', '!=', 0),
|
||||
])
|
||||
|
||||
if not mappings:
|
||||
return 0
|
||||
|
||||
# Build list of remote product template IDs
|
||||
remote_tmpl_ids = mappings.mapped('remote_product_id')
|
||||
|
||||
# Get remote product.product IDs for these templates
|
||||
remote_products = models_proxy.execute_kw(
|
||||
self.db_name, uid, self.api_key,
|
||||
'product.product', 'search_read',
|
||||
[[('product_tmpl_id', 'in', remote_tmpl_ids)]],
|
||||
{'fields': ['id', 'product_tmpl_id', 'qty_available', 'virtual_available']}
|
||||
)
|
||||
|
||||
# Build dict: template_id -> stock info
|
||||
stock_by_tmpl = {}
|
||||
for rp in remote_products:
|
||||
tmpl_id = rp['product_tmpl_id'][0] if isinstance(rp['product_tmpl_id'], list) else rp['product_tmpl_id']
|
||||
if tmpl_id not in stock_by_tmpl:
|
||||
stock_by_tmpl[tmpl_id] = {'qty_available': 0, 'virtual_available': 0}
|
||||
stock_by_tmpl[tmpl_id]['qty_available'] += rp.get('qty_available', 0)
|
||||
stock_by_tmpl[tmpl_id]['virtual_available'] += rp.get('virtual_available', 0)
|
||||
|
||||
updated = 0
|
||||
for mapping in mappings:
|
||||
stock = stock_by_tmpl.get(mapping.remote_product_id, {})
|
||||
mapping.write({
|
||||
'remote_qty_available': stock.get('qty_available', 0),
|
||||
'remote_qty_forecast': stock.get('virtual_available', 0),
|
||||
'last_stock_sync': fields.Datetime.now(),
|
||||
})
|
||||
updated += 1
|
||||
|
||||
return updated
|
||||
|
||||
@api.model
|
||||
def _cron_sync_inventory(self):
|
||||
"""Cron job: run sync for all active configurations."""
|
||||
configs = self.search([('active', '=', True), ('state', '!=', 'draft')])
|
||||
for config in configs:
|
||||
try:
|
||||
config._run_sync()
|
||||
except Exception as e:
|
||||
_logger.error(f'Cron sync failed for {config.name}: {e}')
|
||||
30
fusion_inventory_sync/models/sync_log.py
Normal file
30
fusion_inventory_sync/models/sync_log.py
Normal file
@@ -0,0 +1,30 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from odoo import models, fields
|
||||
|
||||
|
||||
class FusionSyncLog(models.Model):
|
||||
"""Log sync operations for auditing and debugging."""
|
||||
_name = 'fusion.sync.log'
|
||||
_description = 'Inventory Sync Log'
|
||||
_order = 'create_date desc'
|
||||
_rec_name = 'summary'
|
||||
|
||||
config_id = fields.Many2one('fusion.sync.config', string='Sync Config',
|
||||
required=True, ondelete='cascade')
|
||||
direction = fields.Selection([
|
||||
('pull', 'Pull (Remote -> Local)'),
|
||||
('push', 'Push (Local -> Remote)'),
|
||||
], string='Direction', required=True)
|
||||
sync_type = fields.Selection([
|
||||
('product', 'Product Catalog'),
|
||||
('stock', 'Stock Levels'),
|
||||
('full', 'Full Sync'),
|
||||
], string='Type', required=True)
|
||||
status = fields.Selection([
|
||||
('success', 'Success'),
|
||||
('partial', 'Partial'),
|
||||
('error', 'Error'),
|
||||
], string='Status', required=True)
|
||||
summary = fields.Char(string='Summary')
|
||||
details = fields.Text(string='Details')
|
||||
product_count = fields.Integer(string='Products Affected')
|
||||
Reference in New Issue
Block a user