diff --git a/fusion_authorizer_portal/controllers/portal_assessment.py b/fusion_authorizer_portal/controllers/portal_assessment.py index b69c4311..298235dc 100644 --- a/fusion_authorizer_portal/controllers/portal_assessment.py +++ b/fusion_authorizer_portal/controllers/portal_assessment.py @@ -722,7 +722,10 @@ class AssessmentPortal(CustomerPortal): # Post message to chatter with photos sale_order.message_post( - body=f"

Assessment Photos
Photos from assessment {assessment.reference} by {request.env.user.name}

", + body=Markup( + '

Assessment Photos
' + 'Photos from assessment %s by %s

' + ) % (assessment.reference, request.env.user.name), message_type='comment', subtype_xmlid='mail.mt_comment', attachment_ids=attachment_ids, diff --git a/fusion_inventory_sync/__init__.py b/fusion_chatter_enhance/__init__.py similarity index 100% rename from fusion_inventory_sync/__init__.py rename to fusion_chatter_enhance/__init__.py diff --git a/fusion_chatter_enhance/__manifest__.py b/fusion_chatter_enhance/__manifest__.py new file mode 100644 index 00000000..ac22c15d --- /dev/null +++ b/fusion_chatter_enhance/__manifest__.py @@ -0,0 +1,43 @@ +# -*- coding: utf-8 -*- +# Copyright 2024-2026 Nexa Systems Inc. +# License OPL-1 (Odoo Proprietary License v1.0) + +{ + 'name': 'Fusion Chatter Enhance', + 'version': '19.0.1.0.0', + 'category': 'Productivity', + 'summary': 'Resizable, collapsible chatter panel with per-user position preference.', + 'description': """ + Fusion Chatter Enhance + ====================== + + Enhances the Odoo chatter panel with: + - Drag-to-resize: grab the panel edge to change width + - Quick toggle: hover button to show/hide the chatter + - Bottom position: move chatter below the form in user preferences + - Per-user setting: each user picks their own chatter layout + - Icon-only topbar buttons with tooltips for compact display + + Copyright 2024-2026 Nexa Systems Inc. All rights reserved. + """, + 'author': 'Nexa Systems Inc.', + 'website': 'https://www.nexasystems.ca', + 'license': 'OPL-1', + 'depends': [ + 'base', + 'mail', + ], + 'data': [ + 'views/res_users_views.xml', + 'views/templates.xml', + ], + 'assets': { + 'web.assets_backend': [ + 'fusion_chatter_enhance/static/src/scss/chatter_enhance.scss', + 'fusion_chatter_enhance/static/src/js/chatter_panel.js', + ], + }, + 'installable': True, + 'auto_install': False, + 'application': False, +} diff --git a/fusion_chatter_enhance/models/__init__.py b/fusion_chatter_enhance/models/__init__.py new file mode 100644 index 00000000..5f0fe0d9 --- /dev/null +++ b/fusion_chatter_enhance/models/__init__.py @@ -0,0 +1,3 @@ +# -*- coding: utf-8 -*- +from . import ir_http +from . import res_users diff --git a/fusion_chatter_enhance/models/ir_http.py b/fusion_chatter_enhance/models/ir_http.py new file mode 100644 index 00000000..8719aad7 --- /dev/null +++ b/fusion_chatter_enhance/models/ir_http.py @@ -0,0 +1,18 @@ +# -*- coding: utf-8 -*- +# Copyright 2024-2026 Nexa Systems Inc. +# License OPL-1 + +from odoo import models +from odoo.http import request + + +class IrHttp(models.AbstractModel): + _inherit = 'ir.http' + + def session_info(self): + result = super().session_info() + if request.session.uid: + user = self.env.user + result['chatter_position'] = user.chatter_position or 'side' + result['chatter_hidden'] = user.chatter_hidden or False + return result diff --git a/fusion_chatter_enhance/models/res_users.py b/fusion_chatter_enhance/models/res_users.py new file mode 100644 index 00000000..7323b4a2 --- /dev/null +++ b/fusion_chatter_enhance/models/res_users.py @@ -0,0 +1,31 @@ +# -*- coding: utf-8 -*- +# Copyright 2024-2026 Nexa Systems Inc. +# License OPL-1 + +from odoo import fields, models + + +class ResUsers(models.Model): + _inherit = 'res.users' + + chatter_position = fields.Selection( + selection=[ + ('side', 'Side (right of form)'), + ('bottom', 'Bottom (below form)'), + ], + string='Chatter Position', + default='side', + ) + + chatter_hidden = fields.Boolean( + string='Chatter Hidden', + default=False, + ) + + @property + def SELF_READABLE_FIELDS(self): + return super().SELF_READABLE_FIELDS + ['chatter_position', 'chatter_hidden'] + + @property + def SELF_WRITEABLE_FIELDS(self): + return super().SELF_WRITEABLE_FIELDS + ['chatter_position', 'chatter_hidden'] diff --git a/fusion_chatter_enhance/static/description/icon.png b/fusion_chatter_enhance/static/description/icon.png new file mode 100644 index 00000000..a6d0bea6 Binary files /dev/null and b/fusion_chatter_enhance/static/description/icon.png differ diff --git a/fusion_chatter_enhance/static/src/js/chatter_panel.js b/fusion_chatter_enhance/static/src/js/chatter_panel.js new file mode 100644 index 00000000..ed6d1916 --- /dev/null +++ b/fusion_chatter_enhance/static/src/js/chatter_panel.js @@ -0,0 +1,424 @@ +// Fusion Chatter Enhance +// Copyright 2024-2026 Nexa Systems Inc. +// License OPL-1 +// +// Resizable & collapsible chatter panel with per-user position preference. +// +// ARCHITECTURE: +// 1. On script load: read localStorage, inject + + +
+ In Stock + Booked + Incoming (PO) + Out of Stock + Remote +
+ + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ProductSKUCategoryLocalAvailRemoteTotalBookedIncom.PriceMarginAction
+ + + + + + + + + + + + 0 + + + + + + + + + + + + $ + + + % + + +
+ + +
+ -- +
+ Remote + + 00 + + + + + + --$- + +
+
+ + +
+ +
+
+
+
+
+ +
+ + + | + +
+
+
$
+ + % + +
+
+
+
+
+
Local
+
+
+
+ +
+
Avail
+
+
+
+ +
+
Remote
+
+
+
+
Booked
+
+
+
+
Incoming
+
+
+
+
+ + +
+
+
+
+
+
+ + + + +
+ Showing of products + | Auto-refreshes every 30 seconds +
+ + + + + + + + + + + + + diff --git a/fusion_inventory/views/product_attribute_views.xml b/fusion_inventory/views/product_attribute_views.xml new file mode 100644 index 00000000..ad1efee0 --- /dev/null +++ b/fusion_inventory/views/product_attribute_views.xml @@ -0,0 +1,38 @@ + + + + + + product.template.attribute.value.list.fi + product.template.attribute.value + + + + + + + + + + + + product.template.attribute.value.form.fi + product.template.attribute.value + + + + + + + + + + diff --git a/fusion_inventory/views/product_brand_views.xml b/fusion_inventory/views/product_brand_views.xml new file mode 100644 index 00000000..792c2ca4 --- /dev/null +++ b/fusion_inventory/views/product_brand_views.xml @@ -0,0 +1,175 @@ + + + + + + product.brand.form + product.brand + +
+ + + +
+ + +
+ +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+
+ + + + product.brand.list + product.brand + + + + + + + + + + + + + + + + + product.brand.search + product.brand + + + + + + + + + + + + + + + + + + + + + Brands / Vendors + product.brand + list,form + +

+ Create your first brand +

+

+ Brands link products to manufacturers and define tiered pricing + structures. Use pricing rules for category-specific or + product-specific overrides. +

+
+
+ +
diff --git a/fusion_inventory/views/product_template_views.xml b/fusion_inventory/views/product_template_views.xml new file mode 100644 index 00000000..c3c465bf --- /dev/null +++ b/fusion_inventory/views/product_template_views.xml @@ -0,0 +1,174 @@ + + + + + + + product.template.form.fusion.inventory + product.template + + 50 + + + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + + + + + + + + + + + + + + + + + product.template.form.fusion.inventory.taxes + product.template + + + + 1 + + + 1 + + + + + diff --git a/fusion_inventory/views/product_views.xml b/fusion_inventory/views/product_views.xml new file mode 100644 index 00000000..4a171d8d --- /dev/null +++ b/fusion_inventory/views/product_views.xml @@ -0,0 +1,81 @@ + + + + + + product.template.list.sync + product.template + + + + + + + + + + + + + + product.template.search.fusion.inventory + product.template + + + + + + + + + + + + + + + + product.product.form.fusion.inventory + product.product + + + + + + 1 + + + + + + + + diff --git a/fusion_inventory/views/res_config_settings_views.xml b/fusion_inventory/views/res_config_settings_views.xml new file mode 100644 index 00000000..1ebe0859 --- /dev/null +++ b/fusion_inventory/views/res_config_settings_views.xml @@ -0,0 +1,117 @@ + + + + + res.config.settings.view.form.fusion.inventory + res.config.settings + + + + + + +

General

+
+ +
+
+ +
+
+
+
+ +
+
+ Default Margin (%) +
+ Default margin percentage applied to newly created products. +
+
+ % +
+
+
+ +
+
+ Booking Hold Duration +
+ How many hours a product booking holds in the inventory sheet + before automatically expiring. +
+
+ hours +
+
+
+ +
+ + +

Product Name Case Conversion

+
+ +
+
+ Global Case Conversion +
+ Automatically convert ALL product names to the selected case. + This overrides individual product settings and applies to new products. + Set to "No Conversion" to let individual products control their own case. +
+
+ +
+
+
+
+
+ +
+ + +

AI Configuration

+
+ +
+
+ OpenAI API Key +
+ Used for discrepancy analysis and notes parsing. + If empty, falls back to the Fusion Digitize API key. +
+
+ +
+
+
+ +
+ +
+
+
+
+ +
diff --git a/fusion_inventory/views/res_partner_views.xml b/fusion_inventory/views/res_partner_views.xml new file mode 100644 index 00000000..b709b535 --- /dev/null +++ b/fusion_inventory/views/res_partner_views.xml @@ -0,0 +1,24 @@ + + + + + + res.partner.form.fusion.inventory.brand + res.partner + + + + + + + + + + + + +
+ SO Status: + + Invoice: + +
+
+ PO Status: + + Bill: + +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + fusion.inter.company.transfer.list + fusion.inter.company.transfer + + + + + + + + + + + + + + + Inter-Company Transfers + fusion.inter.company.transfer + list,form + + + + + fusion.warehouse.inventory.list + fusion.warehouse.inventory + + + + + + + + + + + + + + Shared Warehouse Inventory + fusion.warehouse.inventory + list + + + + + fusion.inventory.discrepancy.list + fusion.inventory.discrepancy + + + + + + + + + + + + + + + + + fusion.inventory.discrepancy.form + fusion.inventory.discrepancy + +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + +
+
+ + + Discrepancies + fusion.inventory.discrepancy + list,form + {'search_default_state': 'detected'} + + +
diff --git a/fusion_inventory/wizard/__init__.py b/fusion_inventory/wizard/__init__.py new file mode 100644 index 00000000..3507a92e --- /dev/null +++ b/fusion_inventory/wizard/__init__.py @@ -0,0 +1,5 @@ +# -*- coding: utf-8 -*- +# Copyright 2026 Nexa Systems Inc. +# License OPL-1 (Odoo Proprietary License v1.0) + +from . import serial_scan_wizard diff --git a/fusion_inventory/wizard/serial_scan_wizard.py b/fusion_inventory/wizard/serial_scan_wizard.py new file mode 100644 index 00000000..1588e6c2 --- /dev/null +++ b/fusion_inventory/wizard/serial_scan_wizard.py @@ -0,0 +1,135 @@ +# -*- coding: utf-8 -*- +# Copyright 2026 Nexa Systems Inc. +# License OPL-1 (Odoo Proprietary License v1.0) + +import re +import logging +from odoo import models, fields, api + +_logger = logging.getLogger(__name__) + +SKIP_WORDS = frozenset({ + 'total', 'price', 'quantity', 'subtotal', 'discount', 'amount', + 'invoice', 'order', 'product', 'delivery', 'shipping', 'tracking', + 'payment', 'customer', 'vendor', 'notes', 'description', 'reference', + 'number', 'date', 'name', 'address', 'phone', 'email', 'unit', + 'piece', 'each', 'item', 'line', 'from', 'with', 'that', 'this', + 'have', 'will', 'been', 'were', 'would', 'could', 'should', +}) + +SERIAL_PATTERN = re.compile(r'(?]+>', ' ', text or '') + candidates = SERIAL_PATTERN.findall(clean) + + for candidate in candidates: + if len(candidate) < 5: + continue + key = candidate.upper() + if key in seen or candidate.lower() in SKIP_WORDS: + continue + seen.add(key) + + if not re.search(r'\d', candidate): + continue + + lots = self.env['stock.lot'].search([ + ('name', '=ilike', candidate), + ], limit=5) + + matched_product = lots.filtered( + lambda l: l.product_id.id in product_ids) + + results.append({ + 'wizard_id': self.id, + 'serial_text': candidate, + 'source': source_label, + 'found_in_system': bool(lots), + 'matched_product': bool(matched_product), + 'lot_id': matched_product[:1].id if matched_product else ( + lots[:1].id if lots else False), + 'product_id': (matched_product[:1].product_id.id if matched_product + else (lots[:1].product_id.id if lots else False)), + 'lot_product_name': (matched_product[:1].product_id.name if matched_product + else (lots[:1].product_id.name if lots else '')), + }) + + if results: + self.env['fusion.serial.scan.line'].create(results) + + found = sum(1 for r in results if r['found_in_system']) + matched = sum(1 for r in results if r['matched_product']) + self.scan_summary = ( + f'Scanned {len(text_sources)} text sources. ' + f'Found {len(results)} potential serial numbers: ' + f'{found} exist in system, {matched} match products in this transfer.' + ) + + def _collect_text_sources(self, so): + """Gather all text from SO lines, notes, and related invoices.""" + sources = [] + + for line in so.order_line: + if line.name: + sources.append((f'SO Line: {line.product_id.name}', line.name)) + + if so.note: + sources.append(('SO Notes', so.note)) + if so.internal_note if hasattr(so, 'internal_note') else False: + sources.append(('SO Internal Note', so.internal_note)) + + for inv in so.invoice_ids.filtered(lambda m: m.state == 'posted'): + for line in inv.invoice_line_ids: + if line.name: + sources.append(( + f'Invoice {inv.name}: {line.product_id.name if line.product_id else ""}', + line.name)) + if inv.narration: + sources.append((f'Invoice {inv.name} Notes', inv.narration)) + + return sources + + +class FusionSerialScanLine(models.TransientModel): + _name = 'fusion.serial.scan.line' + _description = 'Serial Scan Result Line' + + wizard_id = fields.Many2one( + 'fusion.serial.scan.wizard', ondelete='cascade', required=True) + serial_text = fields.Char(string='Serial Number', readonly=True) + source = fields.Char(string='Found In', readonly=True) + found_in_system = fields.Boolean(string='Exists in System', readonly=True) + matched_product = fields.Boolean( + string='Matches Transfer Product', readonly=True) + lot_id = fields.Many2one('stock.lot', string='Matched Lot', readonly=True) + product_id = fields.Many2one( + 'product.product', string='Lot Product', readonly=True) + lot_product_name = fields.Char(string='Lot Product Name', readonly=True) diff --git a/fusion_inventory/wizard/serial_scan_wizard_views.xml b/fusion_inventory/wizard/serial_scan_wizard_views.xml new file mode 100644 index 00000000..c516eefe --- /dev/null +++ b/fusion_inventory/wizard/serial_scan_wizard_views.xml @@ -0,0 +1,34 @@ + + + + + fusion.serial.scan.wizard.form + fusion.serial.scan.wizard + +
+ + + + + + + + + + + + + + +
+
+
+
+
+ +
diff --git a/fusion_inventory_sync/__manifest__.py b/fusion_inventory_sync/__manifest__.py deleted file mode 100644 index dea1b26f..00000000 --- a/fusion_inventory_sync/__manifest__.py +++ /dev/null @@ -1,32 +0,0 @@ -# -*- coding: utf-8 -*- -{ - 'name': 'Fusion Inventory Sync', - 'version': '19.0.1.0.0', - 'category': 'Inventory', - 'summary': 'Sync inventory between Westin Healthcare and Mobility Specialties via XML-RPC', - 'description': """ - Cross-database inventory sync between two Odoo instances. - - - Connects to a remote Odoo instance via XML-RPC - - Syncs product catalog with mapping table - - Shows remote stock levels on local products - - Cron-based periodic sync (configurable interval) - """, - 'author': 'Nexa Systems Inc.', - 'website': 'https://www.nexasystems.ca', - 'license': 'OPL-1', - 'depends': [ - 'base', - 'stock', - 'product', - ], - 'data': [ - 'security/ir.model.access.csv', - 'data/ir_cron_data.xml', - 'views/sync_config_views.xml', - 'views/product_views.xml', - ], - 'installable': True, - 'auto_install': False, - 'application': False, -} diff --git a/fusion_inventory_sync/data/ir_cron_data.xml b/fusion_inventory_sync/data/ir_cron_data.xml deleted file mode 100644 index 6a024861..00000000 --- a/fusion_inventory_sync/data/ir_cron_data.xml +++ /dev/null @@ -1,14 +0,0 @@ - - - - - Fusion: Sync Remote Inventory - - code - model._cron_sync_inventory() - 30 - minutes - True - - - diff --git a/fusion_inventory_sync/models/__init__.py b/fusion_inventory_sync/models/__init__.py deleted file mode 100644 index 93dbbe19..00000000 --- a/fusion_inventory_sync/models/__init__.py +++ /dev/null @@ -1,6 +0,0 @@ -# -*- coding: utf-8 -*- -from . import sync_config -from . import product_sync_mapping -from . import product_template -from . import stock_move -from . import sync_log diff --git a/fusion_inventory_sync/models/product_template.py b/fusion_inventory_sync/models/product_template.py deleted file mode 100644 index b256b97b..00000000 --- a/fusion_inventory_sync/models/product_template.py +++ /dev/null @@ -1,33 +0,0 @@ -# -*- 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) diff --git a/fusion_inventory_sync/models/stock_move.py b/fusion_inventory_sync/models/stock_move.py deleted file mode 100644 index a4b873c8..00000000 --- a/fusion_inventory_sync/models/stock_move.py +++ /dev/null @@ -1,116 +0,0 @@ -# -*- 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.' - ) diff --git a/fusion_inventory_sync/models/sync_config.py b/fusion_inventory_sync/models/sync_config.py deleted file mode 100644 index 8924bfef..00000000 --- a/fusion_inventory_sync/models/sync_config.py +++ /dev/null @@ -1,257 +0,0 @@ -# -*- 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}') diff --git a/fusion_inventory_sync/security/ir.model.access.csv b/fusion_inventory_sync/security/ir.model.access.csv deleted file mode 100644 index 2b5321d8..00000000 --- a/fusion_inventory_sync/security/ir.model.access.csv +++ /dev/null @@ -1,7 +0,0 @@ -id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink -access_sync_config_manager,fusion.sync.config.manager,model_fusion_sync_config,stock.group_stock_manager,1,1,1,1 -access_sync_config_user,fusion.sync.config.user,model_fusion_sync_config,stock.group_stock_user,1,0,0,0 -access_sync_mapping_manager,fusion.product.sync.mapping.manager,model_fusion_product_sync_mapping,stock.group_stock_manager,1,1,1,1 -access_sync_mapping_user,fusion.product.sync.mapping.user,model_fusion_product_sync_mapping,stock.group_stock_user,1,0,0,0 -access_sync_log_manager,fusion.sync.log.manager,model_fusion_sync_log,stock.group_stock_manager,1,1,1,1 -access_sync_log_user,fusion.sync.log.user,model_fusion_sync_log,stock.group_stock_user,1,0,0,0 diff --git a/fusion_inventory_sync/static/description/icon.png b/fusion_inventory_sync/static/description/icon.png deleted file mode 100644 index adb75fa9..00000000 Binary files a/fusion_inventory_sync/static/description/icon.png and /dev/null differ diff --git a/fusion_inventory_sync/views/product_views.xml b/fusion_inventory_sync/views/product_views.xml deleted file mode 100644 index 00d3a916..00000000 --- a/fusion_inventory_sync/views/product_views.xml +++ /dev/null @@ -1,49 +0,0 @@ - - - - - - product.template.list.sync - product.template - - - - - - - - - - - - product.template.form.sync - product.template - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/fusion_inventory_sync/views/sync_config_views.xml b/fusion_inventory_sync/views/sync_config_views.xml deleted file mode 100644 index 34fa6db9..00000000 --- a/fusion_inventory_sync/views/sync_config_views.xml +++ /dev/null @@ -1,179 +0,0 @@ - - - - - - fusion.sync.config.form - fusion.sync.config - -
-
-
- -
-

-
- - - - - - - - - - - - - - - - - - - - - - - -

- Connect and sync to see product mappings here. -

-
-
-
-
-
-
- - - - fusion.sync.config.list - fusion.sync.config - - - - - - - - - - - - - - fusion.product.sync.mapping.list - fusion.product.sync.mapping - - - - - - - - - - - - - - - - - fusion.product.sync.mapping.search - fusion.product.sync.mapping - - - - - - - - - - - - - - - - - - Sync Configurations - fusion.sync.config - list,form - - - - - Product Mappings - fusion.product.sync.mapping - list - {'search_default_mapped': 1} - - - - - - fusion.sync.log.list - fusion.sync.log - - - - - - - - - - - - - - - Sync Log - fusion.sync.log - list - - - -