import base64 import hashlib import json import logging import secrets from odoo import api, fields, models from odoo.exceptions import UserError from ..lib.woo_api_client import WooApiClient _logger = logging.getLogger(__name__) class WooInstance(models.Model): _name = 'woo.instance' _description = 'WooCommerce Instance' _inherit = ['mail.thread'] name = fields.Char(required=True, tracking=True) url = fields.Char(required=True, tracking=True) consumer_key = fields.Char(groups='base.group_system') consumer_secret = fields.Char(groups='base.group_system') webhook_secret = fields.Char(groups='base.group_system') wc_api_version = fields.Char(default='wc/v3') odoo_api_key = fields.Char(groups='base.group_system') company_id = fields.Many2one( 'res.company', required=True, default=lambda self: self.env.company, ) sync_interval = fields.Selection( [('5', '5 Min'), ('15', '15 Min'), ('30', '30 Min'), ('60', '1 Hour')], default='15', ) sync_products = fields.Boolean(default=True) sync_orders = fields.Boolean(default=True) sync_invoices = fields.Boolean(default=True) sync_inventory = fields.Boolean(default=True) sync_customers = fields.Boolean(default=True) default_warehouse_id = fields.Many2one('stock.warehouse') notify_on_failure = fields.Boolean() notify_user_ids = fields.Many2many('res.users') state = fields.Selection( [('draft', 'Draft'), ('connected', 'Connected'), ('error', 'Error')], default='draft', tracking=True, ) last_sync = fields.Datetime(readonly=True) # Relational product_map_ids = fields.One2many('woo.product.map', 'instance_id') order_ids = fields.One2many('woo.order', 'instance_id') customer_ids = fields.One2many('woo.customer', 'instance_id') sync_log_ids = fields.One2many('woo.sync.log', 'instance_id') # Computed mapped_count = fields.Integer(compute='_compute_counts') unmapped_count = fields.Integer(compute='_compute_counts') error_count = fields.Integer(compute='_compute_counts') @api.depends('product_map_ids.state', 'sync_log_ids') def _compute_counts(self): for rec in self: rec.mapped_count = self.env['woo.product.map'].search_count([ ('instance_id', '=', rec.id), ('state', '=', 'mapped'), ]) rec.unmapped_count = self.env['woo.product.map'].search_count([ ('instance_id', '=', rec.id), ('state', '=', 'unmapped'), ]) yesterday = fields.Datetime.subtract(fields.Datetime.now(), hours=24) rec.error_count = self.env['woo.sync.log'].search_count([ ('instance_id', '=', rec.id), ('state', '=', 'failed'), ('create_date', '>=', yesterday), ]) def _get_client(self): """Return a WooApiClient instance for this WooCommerce connection.""" self.ensure_one() if not self.consumer_key or not self.consumer_secret: raise UserError("Consumer key and secret are required.") return WooApiClient( url=self.url, consumer_key=self.consumer_key, consumer_secret=self.consumer_secret, api_version=self.wc_api_version or 'wc/v3', ) def action_test_connection(self): """Test the WooCommerce connection and update state.""" self.ensure_one() try: client = self._get_client() success, info = client.test_connection() if success: self.state = 'connected' self.message_post(body=f"Connection successful. WooCommerce version: {info}") else: self.state = 'error' self.message_post(body=f"Connection failed: {info}") except Exception as exc: self.state = 'error' self.message_post(body=f"Connection error: {exc}") _logger.exception("WooCommerce connection test failed for %s", self.name) def action_generate_api_key(self): """Generate a random API key for the Odoo webhook endpoint.""" self.ensure_one() self.odoo_api_key = secrets.token_urlsafe(32) def action_view_product_maps(self): """Open product mapping list filtered by this instance.""" self.ensure_one() return { 'type': 'ir.actions.act_window', 'name': 'Product Mapping', 'res_model': 'woo.product.map', 'view_mode': 'list,form', 'domain': [('instance_id', '=', self.id)], 'context': {'default_instance_id': self.id}, } def action_view_sync_errors(self): """Open sync log filtered to errors for this instance.""" self.ensure_one() twenty_four_ago = fields.Datetime.subtract(fields.Datetime.now(), hours=24) return { 'type': 'ir.actions.act_window', 'name': 'Sync Errors (24h)', 'res_model': 'woo.sync.log', 'view_mode': 'list,form', 'domain': [ ('instance_id', '=', self.id), ('state', '=', 'failed'), ('create_date', '>=', twenty_four_ago), ], } def _log_sync(self, sync_type, direction, record_ref, state, message=''): """Create a woo.sync.log record for this instance.""" self.ensure_one() self.env['woo.sync.log'].sudo().create({ 'instance_id': self.id, 'sync_type': sync_type, 'direction': direction, 'record_ref': record_ref, 'state': state, 'message': message, 'company_id': self.company_id.id, }) # ------------------------------------------------------------------ # UI actions (called from OWL product mapping + dashboard) # ------------------------------------------------------------------ def action_fetch_products(self): """Fetch products from WooCommerce and run auto-match.""" self.ensure_one() if self.state != 'connected': raise UserError("Please test the connection first.") client = self._get_client() ProductMap = self.env['woo.product.map'] page = 1 total_fetched = 0 auto_matched = 0 while True: try: products = client.get_products(page=page, per_page=100) except Exception as e: self._log_sync('product', 'woo_to_odoo', self.name, 'failed', str(e)) raise UserError("Failed to fetch products: %s" % str(e)) if not products: break for wc_prod in products: wc_id = wc_prod.get('id') # Skip if already mapped existing = ProductMap.search([ ('instance_id', '=', self.id), ('woo_product_id', '=', wc_id), ], limit=1) if existing: continue wc_sku = wc_prod.get('sku', '') or '' wc_name = wc_prod.get('name', '') wc_type = wc_prod.get('type', 'simple') # Try SKU match odoo_product = False match_state = 'unmapped' if wc_sku: odoo_product = self.env['product.product'].search([ ('default_code', '=', wc_sku), ], limit=1) if odoo_product: match_state = 'mapped' auto_matched += 1 wc_regular_price = 0.0 wc_sale_price = 0.0 try: wc_regular_price = float(wc_prod.get('regular_price') or 0) except (ValueError, TypeError): pass try: wc_sale_price = float(wc_prod.get('sale_price') or 0) except (ValueError, TypeError): pass wc_permalink = wc_prod.get('permalink', '') ProductMap.create({ 'instance_id': self.id, 'product_id': odoo_product.id if odoo_product else False, 'woo_product_id': wc_id, 'woo_product_name': wc_name, 'woo_sku': wc_sku, 'woo_regular_price': wc_regular_price, 'woo_sale_price': wc_sale_price, 'woo_permalink': wc_permalink, 'woo_product_type': wc_type if wc_type in ('simple', 'variable', 'grouped', 'external') else 'simple', 'state': match_state, 'company_id': self.company_id.id, }) total_fetched += 1 # Fetch variations for variable products if wc_type == 'variable': try: var_page = 1 while True: variations = client.get_product_variations(wc_id, page=var_page, per_page=100) if not variations: break for var in variations: var_id = var.get('id') var_existing = ProductMap.search([ ('instance_id', '=', self.id), ('woo_product_id', '=', var_id), ], limit=1) if var_existing: continue var_sku = var.get('sku', '') or '' var_name = wc_name + ' - ' + ', '.join( [a.get('option', '') for a in var.get('attributes', [])] ) var_product = False var_state = 'unmapped' if var_sku: var_product = self.env['product.product'].search([ ('default_code', '=', var_sku), ], limit=1) if var_product: var_state = 'mapped' auto_matched += 1 var_regular = 0.0 var_sale = 0.0 try: var_regular = float(var.get('regular_price') or 0) except (ValueError, TypeError): pass try: var_sale = float(var.get('sale_price') or 0) except (ValueError, TypeError): pass ProductMap.create({ 'instance_id': self.id, 'product_id': var_product.id if var_product else False, 'woo_product_id': var_id, 'woo_product_name': var_name, 'woo_sku': var_sku, 'woo_regular_price': var_regular, 'woo_sale_price': var_sale, 'woo_product_type': 'simple', 'woo_parent_id': wc_id, 'is_variation': True, 'state': var_state, 'company_id': self.company_id.id, }) total_fetched += 1 var_page += 1 if len(variations) < 100: break except Exception: _logger.warning("Failed to fetch variations for product %s", wc_id) page += 1 if len(products) < 100: break self._log_sync('product', 'woo_to_odoo', self.name, 'success', 'Fetched %d products, auto-matched %d by SKU' % (total_fetched, auto_matched)) return True def action_refresh_prices(self): """Refresh WC standard + sale prices for all products from WooCommerce API.""" self.ensure_one() if self.state != 'connected': raise UserError("Instance is not connected.") client = self._get_client() maps = self.env['woo.product.map'].search([ ('instance_id', '=', self.id), ('woo_product_id', '>', 0), ]) updated = 0 for m in maps: try: wc_prod = client.get_product(m.woo_product_id) regular = 0.0 sale = 0.0 try: regular = float(wc_prod.get('regular_price') or 0) except (ValueError, TypeError): pass try: sale = float(wc_prod.get('sale_price') or 0) except (ValueError, TypeError): pass changed = False if abs(regular - (m.woo_regular_price or 0)) > 0.001: m.woo_regular_price = regular changed = True if abs(sale - (m.woo_sale_price or 0)) > 0.001: m.woo_sale_price = sale changed = True if changed: updated += 1 except Exception as e: _logger.warning("Failed to refresh price for WC#%s: %s", m.woo_product_id, e) self._log_sync('product', 'woo_to_odoo', self.name, 'success', 'Refreshed prices for %d products' % updated) return True def action_sync(self): """Manual sync trigger from the UI.""" self.ensure_one() if self.state != 'connected': raise UserError("Instance is not connected.") try: if self.sync_products: self._sync_products() if self.sync_orders: self._sync_orders() if self.sync_inventory: self._sync_inventory() if self.sync_customers: self._sync_customers() self.last_sync = fields.Datetime.now() except Exception as e: _logger.error("Manual sync failed for %s: %s", self.name, str(e)) self._log_sync('product', 'odoo_to_woo', self.name, 'failed', str(e)) raise UserError("Sync failed: %s" % str(e)) return True # ------------------------------------------------------------------ # Cron entry points # ------------------------------------------------------------------ @api.model def _cron_sync_products(self): """Sync product prices and inventory for all connected instances.""" instances = self.search([('state', '=', 'connected'), ('sync_products', '=', True)]) for instance in instances: try: instance._sync_products() except Exception as e: _logger.error("Product sync failed for %s: %s", instance.name, str(e)) instance._log_sync('product', 'woo_to_odoo', instance.name, 'failed', str(e)) instance._notify_failure('product', str(e)) @api.model def _cron_sync_orders(self): """Fetch new orders from all connected WooCommerce instances.""" instances = self.search([('state', '=', 'connected'), ('sync_orders', '=', True)]) for instance in instances: try: instance._sync_orders() except Exception as e: _logger.error("Order sync failed for %s: %s", instance.name, str(e)) instance._log_sync('order', 'woo_to_odoo', instance.name, 'failed', str(e)) instance._notify_failure('order', str(e)) @api.model def _cron_sync_inventory(self): """Push inventory levels to WooCommerce for all connected instances.""" instances = self.search([('state', '=', 'connected'), ('sync_inventory', '=', True)]) for instance in instances: try: instance._sync_inventory() except Exception as e: _logger.error("Inventory sync failed for %s: %s", instance.name, str(e)) instance._log_sync('inventory', 'odoo_to_woo', instance.name, 'failed', str(e)) instance._notify_failure('inventory', str(e)) @api.model def _cron_sync_customers(self): """Sync customer address updates to WooCommerce.""" instances = self.search([('state', '=', 'connected'), ('sync_customers', '=', True)]) for instance in instances: try: instance._sync_customers() except Exception as e: _logger.error("Customer sync failed for %s: %s", instance.name, str(e)) instance._log_sync('customer', 'odoo_to_woo', instance.name, 'failed', str(e)) instance._notify_failure('customer', str(e)) @api.model def _cron_health_check(self): """Ping all connected instances. Only log warnings — never change state. The health check runs from inside Docker which may not reach external URLs. State should only be changed by explicit user action (Test Connection button).""" instances = self.search([('state', '=', 'connected')]) for instance in instances: try: client = instance._get_client() success, _info = client.test_connection() if success: _logger.info("Health check OK for %s", instance.name) else: _logger.warning("Health check failed for %s — store may be unreachable", instance.name) except Exception as e: _logger.warning("Health check error for %s: %s", instance.name, e) # ------------------------------------------------------------------ # Order Sync (Task 20) # ------------------------------------------------------------------ def _sync_orders(self): """Fetch new WC orders and create Odoo SOs.""" self.ensure_one() client = self._get_client() page = 1 while True: orders = client.get_orders(page=page, status='processing,on-hold,pending') if not orders: break for wc_order in orders: try: self._sync_order_from_wc(wc_order) except Exception as e: _logger.error( "Failed to sync WC order %s for %s: %s", wc_order.get('id'), self.name, e, ) self._log_sync( 'order', 'woo_to_odoo', f"WC#{wc_order.get('id')}", 'failed', str(e), ) page += 1 if len(orders) < 100: break self.last_sync = fields.Datetime.now() def _sync_order_from_wc(self, wc_order): """Process a single WC order into an Odoo sale order.""" self.ensure_one() woo_order_id = wc_order['id'] # Dedup check existing = self.env['woo.order'].search([ ('instance_id', '=', self.id), ('woo_order_id', '=', woo_order_id), ], limit=1) if existing: return existing # Find/create customer partner = self._find_or_create_customer(wc_order) # Create sale order so_vals = self._prepare_sale_order_vals(wc_order, partner) sale_order = self.env['sale.order'].create(so_vals) # Add order lines for line_item in wc_order.get('line_items', []): line_vals = self._prepare_order_line_vals(sale_order, line_item) if line_vals: self.env['sale.order.line'].create(line_vals) # Add shipping lines for shipping in wc_order.get('shipping_lines', []): shipping_vals = self._prepare_shipping_line_vals(sale_order, shipping) if shipping_vals: self.env['sale.order.line'].create(shipping_vals) # Add fee lines for fee in wc_order.get('fee_lines', []): fee_vals = { 'order_id': sale_order.id, 'name': fee.get('name', 'Fee'), 'product_uom_qty': 1, 'price_unit': float(fee.get('total', 0)), } self.env['sale.order.line'].create(fee_vals) # Confirm SO sale_order.action_confirm() # Create draft invoice invoice = False if self.sync_invoices: try: invoice = sale_order._create_invoices() except Exception as e: _logger.warning("Could not auto-create invoice for %s: %s", sale_order.name, e) # Create woo.order tracking record woo_order = self.env['woo.order'].create({ 'instance_id': self.id, 'sale_order_id': sale_order.id, 'woo_order_id': woo_order_id, 'woo_order_number': wc_order.get('number', str(woo_order_id)), 'woo_status': wc_order.get('status', ''), 'invoice_id': invoice.id if invoice else False, 'state': 'confirmed', 'company_id': self.company_id.id, }) # Link invoice to woo.order if invoice: invoice.woo_order_id = woo_order.id # Push SO ref back to WC try: client = self._get_client() client.update_order(woo_order_id, { 'meta_data': [ {'key': '_odoo_order_id', 'value': str(sale_order.id)}, {'key': '_odoo_order_ref', 'value': sale_order.name}, ] }) except Exception as e: _logger.warning("Failed to push SO ref to WC: %s", e) self._log_sync( 'order', 'woo_to_odoo', sale_order.name, 'success', f'Created from WC order #{woo_order.woo_order_number}', ) return woo_order def _find_or_create_customer(self, wc_order): """Find or create an Odoo partner from a WC order's billing data.""" self.ensure_one() billing = wc_order.get('billing', {}) email = billing.get('email', '').strip().lower() wc_customer_id = wc_order.get('customer_id', 0) if not email: # Guest checkout — create a one-off partner name = f"{billing.get('first_name', '')} {billing.get('last_name', '')}".strip() return self.env['res.partner'].create({ 'name': name or 'WooCommerce Guest', 'email': '', 'company_id': self.company_id.id, }) # Check existing woo.customer link first if wc_customer_id: woo_cust = self.env['woo.customer'].search([ ('instance_id', '=', self.id), ('woo_customer_id', '=', wc_customer_id), ], limit=1) if woo_cust: return woo_cust.partner_id # Search by email partner = self.env['res.partner'].search([ ('email', '=ilike', email), '|', ('company_id', '=', self.company_id.id), ('company_id', '=', False), ], limit=1) if not partner: partner = self.env['res.partner'].create( self._prepare_partner_vals(billing) ) # Ensure woo.customer link exists if wc_customer_id and not self.env['woo.customer'].search([ ('instance_id', '=', self.id), ('woo_customer_id', '=', wc_customer_id), ], limit=1): self.env['woo.customer'].create({ 'instance_id': self.id, 'partner_id': partner.id, 'woo_customer_id': wc_customer_id, 'woo_email': email, 'last_synced': fields.Datetime.now(), 'company_id': self.company_id.id, }) return partner def _prepare_partner_vals(self, billing): """Build res.partner vals from WC billing data.""" self.ensure_one() name = f"{billing.get('first_name', '')} {billing.get('last_name', '')}".strip() country = self.env['res.country'].search([ ('code', '=', billing.get('country', '')), ], limit=1) state = False if billing.get('state') and country: state = self.env['res.country.state'].search([ ('country_id', '=', country.id), ('code', '=', billing.get('state')), ], limit=1) return { 'name': name or billing.get('email', 'WooCommerce Customer'), 'email': billing.get('email', ''), 'phone': billing.get('phone', ''), 'street': billing.get('address_1', ''), 'street2': billing.get('address_2', ''), 'city': billing.get('city', ''), 'zip': billing.get('postcode', ''), 'country_id': country.id if country else False, 'state_id': state.id if state else False, 'company_id': self.company_id.id, } def _prepare_sale_order_vals(self, wc_order, partner): """Build sale.order vals dict from a WC order.""" self.ensure_one() currency = self.env['res.currency'].search([ ('name', '=', wc_order.get('currency', 'CAD')), ], limit=1) vals = { 'partner_id': partner.id, 'company_id': self.company_id.id, 'client_order_ref': f"WC#{wc_order.get('number', wc_order['id'])}", } if currency: # Set pricelist with matching currency if available pricelist = self.env['product.pricelist'].search([ ('currency_id', '=', currency.id), '|', ('company_id', '=', self.company_id.id), ('company_id', '=', False), ], limit=1) if pricelist: vals['pricelist_id'] = pricelist.id if self.default_warehouse_id: vals['warehouse_id'] = self.default_warehouse_id.id # Shipping address shipping = wc_order.get('shipping', {}) if shipping.get('first_name') or shipping.get('last_name'): ship_name = f"{shipping.get('first_name', '')} {shipping.get('last_name', '')}".strip() ship_country = self.env['res.country'].search([ ('code', '=', shipping.get('country', '')), ], limit=1) ship_state = False if shipping.get('state') and ship_country: ship_state = self.env['res.country.state'].search([ ('country_id', '=', ship_country.id), ('code', '=', shipping.get('state')), ], limit=1) ship_partner = self.env['res.partner'].search([ ('parent_id', '=', partner.id), ('type', '=', 'delivery'), ('street', '=', shipping.get('address_1', '')), ('city', '=', shipping.get('city', '')), ], limit=1) if not ship_partner: ship_partner = self.env['res.partner'].create({ 'parent_id': partner.id, 'type': 'delivery', 'name': ship_name, 'street': shipping.get('address_1', ''), 'street2': shipping.get('address_2', ''), 'city': shipping.get('city', ''), 'zip': shipping.get('postcode', ''), 'country_id': ship_country.id if ship_country else False, 'state_id': ship_state.id if ship_state else False, 'company_id': self.company_id.id, }) vals['partner_shipping_id'] = ship_partner.id return vals def _prepare_order_line_vals(self, sale_order, line_item): """Build sale.order.line vals from a WC line_item dict.""" self.ensure_one() wc_product_id = line_item.get('product_id', 0) wc_variation_id = line_item.get('variation_id', 0) # Look up mapped product lookup_id = wc_variation_id or wc_product_id product_map = self.env['woo.product.map'].search([ ('instance_id', '=', self.id), ('woo_product_id', '=', lookup_id), ('state', '=', 'mapped'), ], limit=1) product = product_map.product_id if product_map else False # If not mapped, try SKU match if not product: sku = line_item.get('sku', '') if sku: product = self.env['product.product'].search([ ('default_code', '=', sku), '|', ('company_id', '=', self.company_id.id), ('company_id', '=', False), ], limit=1) vals = { 'order_id': sale_order.id, 'name': line_item.get('name', 'WooCommerce Product'), 'product_uom_qty': line_item.get('quantity', 1), 'price_unit': float(line_item.get('price', 0)), } if product: vals['product_id'] = product.id # Tax mapping taxes = [] for tax_entry in line_item.get('taxes', []): # WC sends tax class on the line item pass wc_tax_class = line_item.get('tax_class', '') if wc_tax_class: odoo_tax = self.env['woo.tax.map'].get_odoo_tax(self, wc_tax_class) if odoo_tax: taxes.append(odoo_tax.id) elif line_item.get('total_tax') and float(line_item.get('total_tax', 0)) > 0: # Standard tax class odoo_tax = self.env['woo.tax.map'].get_odoo_tax(self, 'standard') if odoo_tax: taxes.append(odoo_tax.id) if taxes: vals['tax_id'] = [(6, 0, taxes)] return vals def _prepare_shipping_line_vals(self, sale_order, shipping_line): """Build sale.order.line vals from a WC shipping_lines entry.""" self.ensure_one() total = float(shipping_line.get('total', 0)) if not total: return False return { 'order_id': sale_order.id, 'name': shipping_line.get('method_title', 'Shipping'), 'product_uom_qty': 1, 'price_unit': total, } # ------------------------------------------------------------------ # Bulk Price Sync (UI actions) # ------------------------------------------------------------------ def action_bulk_price_odoo_to_wc(self): """Push all Odoo prices to WooCommerce for mapped products.""" self.ensure_one() maps = self.env['woo.product.map'].search([ ('instance_id', '=', self.id), ('state', '=', 'mapped'), ('product_id', '!=', False), ]) maps.action_push_price_to_wc() def action_bulk_price_wc_to_odoo(self): """Pull all WC prices to Odoo for mapped products.""" self.ensure_one() maps = self.env['woo.product.map'].search([ ('instance_id', '=', self.id), ('state', '=', 'mapped'), ('product_id', '!=', False), ]) maps.action_push_price_to_odoo() # ------------------------------------------------------------------ # Product / Price Sync (Task 22) # ------------------------------------------------------------------ def _sync_products(self): """Sync product prices between Odoo and WooCommerce.""" self.ensure_one() client = self._get_client() maps = self.env['woo.product.map'].search([ ('instance_id', '=', self.id), ('state', '=', 'mapped'), ('sync_price', '=', True), ('product_id', '!=', False), ]) for pm in maps: try: wc_product = client.get_product(pm.woo_product_id) wc_price = float(wc_product.get('regular_price') or 0) odoo_price = pm.product_id.list_price if abs(wc_price - odoo_price) < 0.01: # Prices match — nothing to do pm.last_synced = fields.Datetime.now() continue # Check if Odoo price was changed since last sync odoo_changed = True woo_changed = True if pm.last_synced: # If product write_date is newer than last sync, Odoo changed odoo_changed = pm.product_id.write_date > pm.last_synced # WC always returns current price, assume changed if different woo_changed = True if odoo_changed and woo_changed: # Both changed — conflict self.env['woo.conflict'].create({ 'instance_id': self.id, 'conflict_type': 'product', 'map_id': pm.id, 'field_name': 'price', 'odoo_value': str(odoo_price), 'woo_value': str(wc_price), 'company_id': self.company_id.id, }) # Keep state as mapped — conflict is tracked separately self._log_sync( 'product', 'woo_to_odoo', pm.product_id.display_name, 'conflict', f'Price conflict: Odoo=${odoo_price}, WC=${wc_price}', ) elif odoo_changed: # Odoo is authoritative — push to WC client.update_product(pm.woo_product_id, { 'regular_price': str(odoo_price), }) self._log_sync( 'product', 'odoo_to_woo', pm.product_id.display_name, 'success', f'Price updated to ${odoo_price}', ) else: # WC changed — pull into Odoo pm.product_id.list_price = wc_price self._log_sync( 'product', 'woo_to_odoo', pm.product_id.display_name, 'success', f'Price updated to ${wc_price}', ) pm.last_synced = fields.Datetime.now() except Exception as e: _logger.error( "Product price sync failed for %s (WC#%s): %s", pm.product_id.display_name, pm.woo_product_id, e, ) # Don't change map state on sync errors — keep it mapped self._log_sync( 'product', 'odoo_to_woo', pm.product_id.display_name, 'failed', str(e), ) def _sync_product_from_wc(self, wc_data): """Handle an inbound WC product webhook — update price if mapped.""" self.ensure_one() wc_product_id = wc_data.get('id') if not wc_product_id: return pm = self.env['woo.product.map'].search([ ('instance_id', '=', self.id), ('woo_product_id', '=', wc_product_id), ('state', '=', 'mapped'), ], limit=1) if not pm or not pm.product_id: return if pm.sync_price: wc_price = float(wc_data.get('regular_price') or 0) if wc_price and abs(wc_price - pm.product_id.list_price) > 0.01: pm.product_id.list_price = wc_price self._log_sync( 'product', 'woo_to_odoo', pm.product_id.display_name, 'success', f'Price updated via webhook to ${wc_price}', ) pm.last_synced = fields.Datetime.now() # ------------------------------------------------------------------ # Inventory Sync (Task 22) # ------------------------------------------------------------------ def _sync_inventory(self): """Push Odoo stock levels to WooCommerce.""" self.ensure_one() client = self._get_client() maps = self.env['woo.product.map'].search([ ('instance_id', '=', self.id), ('state', '=', 'mapped'), ('sync_inventory', '=', True), ('product_id', '!=', False), ]) for pm in maps: try: product = pm.product_id if self.default_warehouse_id: # Get qty for specific warehouse quant = self.env['stock.quant'].search([ ('product_id', '=', product.id), ('location_id', 'child_of', self.default_warehouse_id.lot_stock_id.id), ]) qty = sum(quant.mapped('quantity')) - sum(quant.mapped('reserved_quantity')) else: qty = product.qty_available qty = max(int(qty), 0) client.update_product(pm.woo_product_id, { 'stock_quantity': qty, 'manage_stock': True, }) pm.last_synced = fields.Datetime.now() self._log_sync( 'inventory', 'odoo_to_woo', product.display_name, 'success', f'Stock set to {qty}', ) except Exception as e: _logger.error( "Inventory sync failed for %s (WC#%s): %s", pm.product_id.display_name, pm.woo_product_id, e, ) self._log_sync( 'inventory', 'odoo_to_woo', pm.product_id.display_name, 'failed', str(e), ) # ------------------------------------------------------------------ # Customer Sync (Task 25) # ------------------------------------------------------------------ def _sync_customers(self): """Push updated Odoo partner addresses to WooCommerce.""" self.ensure_one() client = self._get_client() customers = self.env['woo.customer'].search([ ('instance_id', '=', self.id), ('woo_customer_id', '>', 0), ]) for cust in customers: try: partner = cust.partner_id # Only sync if partner was modified since last sync if cust.last_synced and partner.write_date <= cust.last_synced: continue billing_data = { 'first_name': (partner.name or '').split(' ', 1)[0], 'last_name': (partner.name or '').split(' ', 1)[1] if ' ' in (partner.name or '') else '', 'email': partner.email or '', 'phone': partner.phone or '', 'address_1': partner.street or '', 'address_2': partner.street2 or '', 'city': partner.city or '', 'postcode': partner.zip or '', 'country': partner.country_id.code if partner.country_id else '', 'state': partner.state_id.code if partner.state_id else '', } client.update_customer(cust.woo_customer_id, { 'billing': billing_data, 'shipping': billing_data, }) cust.last_synced = fields.Datetime.now() self._log_sync( 'customer', 'odoo_to_woo', partner.display_name, 'success', 'Address updated in WooCommerce', ) except Exception as e: _logger.error( "Customer sync failed for %s (WC#%s): %s", cust.partner_id.display_name, cust.woo_customer_id, e, ) self._log_sync( 'customer', 'odoo_to_woo', cust.partner_id.display_name, 'failed', str(e), ) def _sync_customer_from_wc(self, wc_data): """Handle an inbound WC customer webhook — update partner if linked.""" self.ensure_one() wc_customer_id = wc_data.get('id') email = wc_data.get('email', '').strip().lower() if not wc_customer_id: return cust = self.env['woo.customer'].search([ ('instance_id', '=', self.id), ('woo_customer_id', '=', wc_customer_id), ], limit=1) billing = wc_data.get('billing', {}) if cust: partner = cust.partner_id # Update partner address from WC vals = self._prepare_partner_vals(billing) vals.pop('company_id', None) partner.write(vals) cust.last_synced = fields.Datetime.now() self._log_sync( 'customer', 'woo_to_odoo', partner.display_name, 'success', 'Updated from WC webhook', ) elif email: # New customer from WC self.env['woo.customer']._find_or_create(self, email, wc_data) # ------------------------------------------------------------------ # Notification # ------------------------------------------------------------------ def _notify_failure(self, sync_type, error_message): """Send email notification on sync failure.""" self.ensure_one() if not self.notify_on_failure or not self.notify_user_ids: return template = self.env.ref( 'fusion_woocommerce.woo_sync_failure_notification', raise_if_not_found=False, ) if template: for user in self.notify_user_ids: template.with_context( sync_type=sync_type, error_message=error_message, ).send_mail(self.id, force_send=True, email_values={'email_to': user.email})