# -*- 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}')