# -*- coding: utf-8 -*- # Copyright 2026 Nexa Systems Inc. # License OPL-1 (Odoo Proprietary License v1.0) import logging import xmlrpc.client from odoo import models, fields, api from odoo.exceptions import UserError _logger = logging.getLogger(__name__) class FusionSyncConfig(models.Model): _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_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.') is_shared_warehouse = fields.Boolean( string='Shared Warehouse', help='Enable shared warehouse mode for cross-company inventory management') warehouse_location_id = fields.Many2one( 'stock.location', string='Shared Warehouse Location', help='The stock location representing the shared warehouse') remote_partner_id = fields.Many2one( 'res.partner', string='Remote Company (Partner)', help='The local partner record that represents the remote company. ' 'Used as vendor when creating local POs for inter-company transfers.') local_company_name = fields.Char( string='This Company on Remote', help='The name of this company as it appears on the remote instance. ' 'Used to find the correct partner when creating SOs on the remote side.') sync_warehouse_ids = fields.One2many( 'fusion.sync.warehouse', 'config_id', string='Remote Warehouses') sync_warehouse_count = fields.Integer( compute='_compute_sync_warehouse_count') @api.depends('sync_warehouse_ids') def _compute_sync_warehouse_count(self): for rec in self: rec.sync_warehouse_count = len(rec.sync_warehouse_ids) # ── XML-RPC Connection ── def _get_xmlrpc_connection(self): 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)}') # ── Actions ── def action_test_connection(self): self.ensure_one() try: uid, models_proxy = self._get_xmlrpc_connection() 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): 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, }, } # ── Core Sync Logic ── def _run_sync(self): self.ensure_one() try: uid, models_proxy = self._get_xmlrpc_connection() results = [] wh_count = self._sync_warehouses(uid, models_proxy) results.append(f'{wh_count} warehouses discovered') 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) self.write({ 'state': 'connected', 'last_sync': fields.Datetime.now(), 'last_sync_status': status, }) 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('Inventory sync complete for %s: %s', self.name, 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, }) self.env['fusion.sync.log'].create({ 'config_id': self.id, 'direction': 'pull', 'sync_type': 'full', 'status': 'error', 'summary': error_msg, }) _logger.error('Sync failed for %s: %s', self.name, error_msg) def _sync_warehouses(self, uid, models_proxy): """Discover and store remote warehouse metadata. Only syncs warehouses belonging to the remote user's main company.""" self.ensure_one() SyncWH = self.env['fusion.sync.warehouse'] user_data = models_proxy.execute_kw( self.db_name, uid, self.api_key, 'res.users', 'read', [[uid]], {'fields': ['company_id']} ) wh_domain = [] if user_data and user_data[0].get('company_id'): remote_company_id = user_data[0]['company_id'] if isinstance(remote_company_id, (list, tuple)): remote_company_id = remote_company_id[0] wh_domain = [('company_id', '=', remote_company_id)] remote_warehouses = models_proxy.execute_kw( self.db_name, uid, self.api_key, 'stock.warehouse', 'search_read', [wh_domain], {'fields': ['id', 'name', 'code', 'company_id', 'lot_stock_id']} ) seen_ids = set() for rwh in remote_warehouses: wh_id = rwh['id'] seen_ids.add(wh_id) company = rwh.get('company_id') company_name = company[1] if isinstance(company, (list, tuple)) else '' lot_stock = rwh.get('lot_stock_id') lot_stock_id = lot_stock[0] if isinstance(lot_stock, (list, tuple)) else (lot_stock or 0) existing = SyncWH.search([ ('config_id', '=', self.id), ('remote_warehouse_id', '=', wh_id), ], limit=1) vals = { 'config_id': self.id, 'remote_warehouse_id': wh_id, 'remote_lot_stock_id': lot_stock_id, 'name': rwh.get('name', ''), 'code': rwh.get('code', ''), 'company_name': company_name, } if existing: existing.write(vals) else: SyncWH.create(vals) stale = SyncWH.search([ ('config_id', '=', self.id), ('remote_warehouse_id', 'not in', list(seen_ids)), ]) if stale: stale.write({'active': False}) return len(remote_warehouses) def _sync_products(self, uid, models_proxy): """Pull remote product catalog and auto-match to local products.""" self.ensure_one() Mapping = self.env['fusion.product.sync.mapping'] 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', 'barcode', 'list_price', 'type', 'categ_id'], 'limit': 10000} ) synced = 0 for rp in remote_products: 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_barcode': rp.get('barcode', '') or '', 'remote_list_price': rp.get('list_price', 0), 'remote_category': ( rp['categ_id'][1] if isinstance(rp.get('categ_id'), (list, tuple)) else ''), } if mapping: mapping.write(vals) else: local_product = self._find_local_product(rp) 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 _find_local_product(self, remote_product): """Match a remote product to a local one by SKU, barcode, then name.""" Template = self.env['product.template'] code = remote_product.get('default_code') if code: match = Template.search([('default_code', '=', code)], limit=1) if match: return match barcode = remote_product.get('barcode') if barcode: match = Template.search([('barcode', '=', barcode)], limit=1) if match: return match name = remote_product.get('name') if name: match = Template.search([('name', '=ilike', name)], limit=1) if match: return match return False def _sync_stock_levels(self, uid, models_proxy): """Pull per-warehouse stock levels and store in fusion.sync.stock.""" self.ensure_one() Mapping = self.env['fusion.product.sync.mapping'] SyncStock = self.env['fusion.sync.stock'] warehouses = self.sync_warehouse_ids.filtered('active') if not warehouses: _logger.info('No remote warehouses found for %s, skipping stock sync', self.name) return 0 mappings = Mapping.search([ ('config_id', '=', self.id), ('remote_product_id', '!=', 0), ]) if not mappings: return 0 remote_tmpl_ids = [m.remote_product_id for m in mappings] mapping_by_remote = {m.remote_product_id: m for m in mappings} total_updated = 0 now = fields.Datetime.now() for wh in warehouses: if not wh.remote_lot_stock_id: continue try: remote_quants = models_proxy.execute_kw( self.db_name, uid, self.api_key, 'stock.quant', 'search_read', [[ ('location_id', 'child_of', wh.remote_lot_stock_id), ('product_id.product_tmpl_id', 'in', remote_tmpl_ids), ('quantity', '!=', 0), ]], {'fields': ['product_id', 'quantity', 'reserved_quantity']} ) except Exception as e: _logger.warning( 'Stock sync failed for warehouse %s: %s', wh.name, e) continue stock_by_tmpl = {} product_tmpl_cache = {} product_ids_needed = set() for q in remote_quants: pid = q['product_id'] if isinstance(pid, (list, tuple)): pid = pid[0] product_ids_needed.add(pid) if product_ids_needed: remote_variants = models_proxy.execute_kw( self.db_name, uid, self.api_key, 'product.product', 'search_read', [[('id', 'in', list(product_ids_needed))]], {'fields': ['id', 'product_tmpl_id']} ) for rv in remote_variants: tmpl = rv['product_tmpl_id'] tmpl_id = tmpl[0] if isinstance(tmpl, (list, tuple)) else tmpl product_tmpl_cache[rv['id']] = tmpl_id for q in remote_quants: pid = q['product_id'] if isinstance(pid, (list, tuple)): pid = pid[0] tmpl_id = product_tmpl_cache.get(pid) if not tmpl_id: continue if tmpl_id not in stock_by_tmpl: stock_by_tmpl[tmpl_id] = {'qty': 0.0, 'reserved': 0.0} stock_by_tmpl[tmpl_id]['qty'] += q.get('quantity', 0) stock_by_tmpl[tmpl_id]['reserved'] += q.get('reserved_quantity', 0) for tmpl_id, stock in stock_by_tmpl.items(): mapping = mapping_by_remote.get(tmpl_id) if not mapping: continue qty_available = stock['qty'] - stock['reserved'] qty_forecast = stock['qty'] existing = SyncStock.search([ ('mapping_id', '=', mapping.id), ('sync_warehouse_id', '=', wh.id), ], limit=1) vals = { 'mapping_id': mapping.id, 'sync_warehouse_id': wh.id, 'qty_available': qty_available, 'qty_forecast': qty_forecast, 'last_sync': now, } if existing: existing.write(vals) else: SyncStock.create(vals) total_updated += 1 zero_mappings = set(mapping_by_remote.keys()) - set(stock_by_tmpl.keys()) if zero_mappings: for tmpl_id in zero_mappings: mapping = mapping_by_remote.get(tmpl_id) if not mapping: continue existing = SyncStock.search([ ('mapping_id', '=', mapping.id), ('sync_warehouse_id', '=', wh.id), ], limit=1) if existing and existing.qty_available != 0: existing.write({ 'qty_available': 0, 'qty_forecast': 0, 'last_sync': now, }) for mapping in mappings: mapping.last_stock_sync = now return total_updated def _sync_stock_for_products(self, product_tmpl_ids): """Targeted stock re-sync for specific products (called after stock moves).""" self.ensure_one() if not product_tmpl_ids: return Mapping = self.env['fusion.product.sync.mapping'] mappings = Mapping.search([ ('config_id', '=', self.id), ('local_product_id', 'in', product_tmpl_ids), ('remote_product_id', '!=', 0), ]) if not mappings: return try: uid, models_proxy = self._get_xmlrpc_connection() except Exception: return warehouses = self.sync_warehouse_ids.filtered('active') SyncStock = self.env['fusion.sync.stock'] now = fields.Datetime.now() remote_tmpl_ids = [m.remote_product_id for m in mappings] mapping_by_remote = {m.remote_product_id: m for m in mappings} for wh in warehouses: if not wh.remote_lot_stock_id: continue try: remote_quants = models_proxy.execute_kw( self.db_name, uid, self.api_key, 'stock.quant', 'search_read', [[ ('location_id', 'child_of', wh.remote_lot_stock_id), ('product_id.product_tmpl_id', 'in', remote_tmpl_ids), ]], {'fields': ['product_id', 'quantity', 'reserved_quantity']} ) except Exception: continue stock_by_tmpl = {} product_ids_needed = set() for q in remote_quants: pid = q['product_id'] if isinstance(pid, (list, tuple)): pid = pid[0] product_ids_needed.add(pid) product_tmpl_cache = {} if product_ids_needed: remote_variants = models_proxy.execute_kw( self.db_name, uid, self.api_key, 'product.product', 'search_read', [[('id', 'in', list(product_ids_needed))]], {'fields': ['id', 'product_tmpl_id']} ) for rv in remote_variants: tmpl = rv['product_tmpl_id'] product_tmpl_cache[rv['id']] = ( tmpl[0] if isinstance(tmpl, (list, tuple)) else tmpl) for q in remote_quants: pid = q['product_id'] if isinstance(pid, (list, tuple)): pid = pid[0] tmpl_id = product_tmpl_cache.get(pid) if not tmpl_id: continue if tmpl_id not in stock_by_tmpl: stock_by_tmpl[tmpl_id] = {'qty': 0.0, 'reserved': 0.0} stock_by_tmpl[tmpl_id]['qty'] += q.get('quantity', 0) stock_by_tmpl[tmpl_id]['reserved'] += q.get('reserved_quantity', 0) for tmpl_id in remote_tmpl_ids: mapping = mapping_by_remote.get(tmpl_id) if not mapping: continue stock = stock_by_tmpl.get(tmpl_id, {'qty': 0, 'reserved': 0}) existing = SyncStock.search([ ('mapping_id', '=', mapping.id), ('sync_warehouse_id', '=', wh.id), ], limit=1) vals = { 'mapping_id': mapping.id, 'sync_warehouse_id': wh.id, 'qty_available': stock['qty'] - stock['reserved'], 'qty_forecast': stock['qty'], 'last_sync': now, } if existing: existing.write(vals) else: SyncStock.create(vals) # ── Inter-Company Transfer Helpers ── def _create_remote_sale_order(self, product_mapping, qty, partner_name): """Create a sale order on the remote instance for inter-company transfers.""" self.ensure_one() uid, models_proxy = self._get_xmlrpc_connection() partners = models_proxy.execute_kw( self.db_name, uid, self.api_key, 'res.partner', 'search_read', [[('name', 'ilike', partner_name)]], {'fields': ['id', 'name'], 'limit': 1} ) if not partners: raise UserError(f'Partner "{partner_name}" not found on remote instance.') remote_product_ids = models_proxy.execute_kw( self.db_name, uid, self.api_key, 'product.product', 'search', [[('product_tmpl_id', '=', product_mapping.remote_product_id)]], {'limit': 1} ) if not remote_product_ids: raise UserError('Remote product variant not found.') so_vals = { 'partner_id': partners[0]['id'], 'order_line': [(0, 0, { 'product_id': remote_product_ids[0], 'product_uom_qty': qty, })], } remote_so_id = models_proxy.execute_kw( self.db_name, uid, self.api_key, 'sale.order', 'create', [so_vals] ) models_proxy.execute_kw( self.db_name, uid, self.api_key, 'sale.order', 'action_confirm', [[remote_so_id]] ) so_data = models_proxy.execute_kw( self.db_name, uid, self.api_key, 'sale.order', 'read', [[remote_so_id]], {'fields': ['name']} ) so_name = so_data[0]['name'] if so_data else '' return remote_so_id, so_name def _create_remote_invoice(self, remote_so_id): """Create and post an invoice for a remote sale order.""" self.ensure_one() uid, models_proxy = self._get_xmlrpc_connection() models_proxy.execute_kw( self.db_name, uid, self.api_key, 'sale.order', 'action_create_invoices', [[remote_so_id]] ) so_data = models_proxy.execute_kw( self.db_name, uid, self.api_key, 'sale.order', 'read', [[remote_so_id]], {'fields': ['invoice_ids']} ) invoice_ids = so_data[0].get('invoice_ids', []) if so_data else [] if invoice_ids: inv_data = models_proxy.execute_kw( self.db_name, uid, self.api_key, 'account.move', 'read', [invoice_ids], {'fields': ['id', 'name', 'amount_total']} ) return inv_data[0]['id'] if inv_data else False return False # ── Cron ── @api.model def _cron_sync_inventory(self): configs = self.search([('active', '=', True), ('state', '!=', 'draft')]) for config in configs: try: config._run_sync() except Exception as e: _logger.error('Cron sync failed for %s: %s', config.name, e)