import base64 import hashlib import json import logging import requests from odoo import fields, models from odoo.exceptions import UserError _logger = logging.getLogger(__name__) class WooProductMap(models.Model): _name = 'woo.product.map' _description = 'WooCommerce Product Mapping' _rec_name = 'woo_product_name' instance_id = fields.Many2one('woo.instance', required=True, ondelete='cascade') product_id = fields.Many2one('product.product') woo_product_id = fields.Integer() woo_product_name = fields.Char() woo_sku = fields.Char() woo_product_type = fields.Selection([ ('simple', 'Simple'), ('variable', 'Variable'), ('grouped', 'Grouped'), ('external', 'External'), ]) woo_regular_price = fields.Float(string='WC Standard Price', digits='Product Price') woo_sale_price = fields.Float(string='WC Sale Price', digits='Product Price') woo_permalink = fields.Char(string='WC Product URL') woo_category_id = fields.Integer(string='WC Category ID') woo_category_name = fields.Char(string='WC Category') woo_parent_id = fields.Integer() is_variation = fields.Boolean() sync_price = fields.Boolean(default=True) sync_inventory = fields.Boolean(default=True) sync_images = fields.Boolean(default=True) woo_image_ids = fields.Char() # JSON last_synced = fields.Datetime() company_id = fields.Many2one( 'res.company', required=True, default=lambda self: self.env.company, ) state = fields.Selection([ ('unmapped', 'Unmapped'), ('mapped', 'Mapped'), ('conflict', 'Conflict'), ('error', 'Error'), ], default='unmapped') # ------------------------------------------------------------------ # Individual Price Sync # ------------------------------------------------------------------ def action_push_price_to_odoo(self): """Update Odoo product price from WC sale price (or regular if no sale).""" for rec in self: if not rec.product_id: continue # Use sale price if available, otherwise regular price wc_price = rec.woo_sale_price if rec.woo_sale_price else rec.woo_regular_price if wc_price: rec.product_id.list_price = wc_price rec.instance_id._log_sync( 'product', 'woo_to_odoo', rec.product_id.name, 'success', 'Odoo price updated from WC: $%.2f' % wc_price, ) def action_push_price_to_wc(self): """Push Odoo price to WC. Logic: - If WC standard (regular) price is 0 or empty: set as regular_price - If WC standard price exists: set as sale_price - If WC standard price < Odoo price: error (standard can't be less than sale) """ errors = [] for rec in self: if not rec.product_id or not rec.instance_id: continue odoo_price = rec.product_id.list_price wc_regular = rec.woo_regular_price or 0.0 client = rec.instance_id._get_client() update_data = {} if wc_regular < 0.01: # No standard price set — push as regular_price update_data = { 'regular_price': str(odoo_price), 'sale_price': '', } rec.woo_regular_price = odoo_price rec.woo_sale_price = 0.0 else: # Standard price exists — push as sale_price if wc_regular < odoo_price - 0.01: # Standard price is less than the price we want to set as sale errors.append( '%s: WC standard price ($%.2f) is less than Odoo price ($%.2f). ' 'Update the standard price first.' % (rec.woo_product_name, wc_regular, odoo_price) ) continue update_data = { 'sale_price': str(odoo_price), } rec.woo_sale_price = odoo_price try: client.update_product(rec.woo_product_id, update_data) rec.instance_id._log_sync( 'product', 'odoo_to_woo', rec.product_id.name, 'success', 'Price pushed to WC: $%.2f' % odoo_price, ) except Exception as e: errors.append('%s: %s' % (rec.woo_product_name, str(e))) if errors: raise UserError('\n'.join(errors)) def action_set_regular_price(self, price): """Set the WC standard (regular) price directly.""" self.ensure_one() if not self.instance_id: return client = self.instance_id._get_client() # If there's a sale price, regular must be >= sale if self.woo_sale_price and price < self.woo_sale_price - 0.01: raise UserError( 'Standard price ($%.2f) cannot be less than the current sale price ($%.2f).' % (price, self.woo_sale_price) ) client.update_product(self.woo_product_id, {'regular_price': str(price)}) self.woo_regular_price = price self.instance_id._log_sync( 'product', 'odoo_to_woo', self.woo_product_name, 'success', 'Standard price set to $%.2f' % price, ) def action_set_sale_price(self, price): """Set the WC sale price directly.""" self.ensure_one() if not self.instance_id: return client = self.instance_id._get_client() # Sale price cannot exceed regular price if self.woo_regular_price and price > self.woo_regular_price + 0.01: raise UserError( 'Sale price ($%.2f) cannot exceed the standard price ($%.2f).' % (price, self.woo_regular_price) ) update_data = {'sale_price': str(price) if price > 0 else ''} client.update_product(self.woo_product_id, update_data) self.woo_sale_price = price self.instance_id._log_sync( 'product', 'odoo_to_woo', self.woo_product_name, 'success', 'Sale price set to $%.2f' % price, ) # ------------------------------------------------------------------ # Push Variants to WooCommerce # ------------------------------------------------------------------ def action_push_variants_to_wc(self): """Convert a simple WC product to variable and create variations from Odoo variants.""" self.ensure_one() if not self.product_id or not self.instance_id: raise UserError("Product or instance not set.") tmpl = self.product_id.product_tmpl_id variants = tmpl.product_variant_ids if len(variants) <= 1: raise UserError("This product has no variants in Odoo.") client = self.instance_id._get_client() inst = self.instance_id # Step 1: Build WC attributes from Odoo attribute lines wc_attributes = [] for attr_line in tmpl.attribute_line_ids: attr_name = attr_line.attribute_id.name attr_values = attr_line.value_ids.mapped('name') # Find or create WC attribute wc_attr = self._find_or_create_wc_attribute(client, attr_name) # Create terms for each value wc_terms = [] for val_name in attr_values: term = self._find_or_create_wc_attribute_term(client, wc_attr['id'], val_name) wc_terms.append(term['name']) wc_attributes.append({ 'id': wc_attr['id'], 'name': attr_name, 'position': 0, 'visible': True, 'variation': True, 'options': wc_terms, }) # Step 2: Update the WC product from simple → variable with attributes try: client.update_product(self.woo_product_id, { 'type': 'variable', 'attributes': wc_attributes, }) self.woo_product_type = 'variable' except Exception as e: raise UserError("Failed to convert WC product to variable: %s" % str(e)) # Build WC attribute ID lookup wc_attr_id_map = {a['name'].upper(): a['id'] for a in wc_attributes} # Step 3: Create a WC variation for each Odoo variant created = 0 for variant in variants: existing = self.search([ ('instance_id', '=', inst.id), ('product_id', '=', variant.id), ('is_variation', '=', True), ], limit=1) if existing: continue # Build variation attributes with WC IDs var_attributes = [] for ptav in variant.product_template_attribute_value_ids: attr_name = ptav.attribute_id.name wc_aid = wc_attr_id_map.get(attr_name.upper(), 0) entry = {'option': ptav.name} if wc_aid: entry['id'] = wc_aid else: entry['name'] = attr_name var_attributes.append(entry) var_data = { 'regular_price': str(variant.list_price), 'sku': variant.default_code or '', 'attributes': var_attributes, 'manage_stock': True, 'stock_quantity': int(variant.qty_available), } # Tax class wc_tax_class = self.env['woo.tax.map'].get_wc_tax_class(inst, variant.taxes_id[:1].id if variant.taxes_id else False) if wc_tax_class: var_data['tax_class'] = wc_tax_class # Variant image — pass Odoo's public URL, WC downloads it directly if variant.image_variant_1920: odoo_base = inst.env['ir.config_parameter'].sudo().get_param('web.base.url', '') filename = (variant.default_code or 'variant') + '.png' if odoo_base: img_url = f"{odoo_base}/web/image/product.product/{variant.id}/image_1920/{filename}" var_data['image'] = { 'src': img_url, 'name': filename, 'alt': variant.display_name, } try: wc_variation = client.create_product_variation(self.woo_product_id, var_data) # Create variation product map self.create({ 'instance_id': inst.id, 'product_id': variant.id, 'woo_product_id': wc_variation['id'], 'woo_product_name': variant.display_name, 'woo_sku': variant.default_code or '', 'woo_regular_price': variant.list_price, 'woo_sale_price': 0, 'woo_permalink': self.woo_permalink or '', 'woo_product_type': 'simple', 'woo_parent_id': self.woo_product_id, 'is_variation': True, 'state': 'mapped', 'company_id': inst.company_id.id, }) created += 1 except Exception as e: _logger.error("Failed to create variation for %s: %s", variant.display_name, e) inst._log_sync('product', 'odoo_to_woo', tmpl.name, 'success', 'Pushed %d variants to WC product #%s' % (created, self.woo_product_id)) return True def _find_or_create_wc_attribute(self, client, attr_name): """Find or create a WC product attribute by name.""" try: attrs = client.get_product_attributes() for a in attrs: if a.get('name', '').lower() == attr_name.lower(): return a except Exception: pass return client.create_product_attribute({ 'name': attr_name, 'slug': attr_name.lower().replace(' ', '-'), 'type': 'select', 'order_by': 'menu_order', }) def _find_or_create_wc_attribute_term(self, client, attr_id, term_name): """Find or create a WC attribute term.""" try: terms = client.get_attribute_terms(attr_id) for t in terms: if t.get('name', '').lower() == term_name.lower(): return t except Exception: pass return client.create_attribute_term(attr_id, {'name': term_name}) # ------------------------------------------------------------------ # Create in Odoo (from unmapped WC product) # ------------------------------------------------------------------ def action_create_in_odoo(self): """Create an Odoo product from WC mapping data, link the mapping, and return the new product ID so the JS can open the form.""" self.ensure_one() if self.product_id: raise UserError("This mapping already has an Odoo product linked.") wc_price = self.woo_sale_price or self.woo_regular_price or 0.0 # Resolve Odoo category from WC category mapping categ_id = False if self.woo_category_id and self.instance_id: cat_map = self.env['woo.category.map'].search([ ('instance_id', '=', self.instance_id.id), ('woo_category_id', '=', self.woo_category_id), ('odoo_category_id', '!=', False), ], limit=1) if cat_map: categ_id = cat_map.odoo_category_id.id product_vals = { 'name': (self.woo_product_name or 'New Product').upper(), 'default_code': self.woo_sku or '', 'list_price': wc_price, 'type': 'consu', } if categ_id: product_vals['categ_id'] = categ_id product = self.env['product.product'].create(product_vals) self.write({ 'product_id': product.id, 'state': 'mapped', }) if self.instance_id: self.instance_id._log_sync( 'product', 'woo_to_odoo', product.name, 'success', 'Created Odoo product from WC #%s' % self.woo_product_id, ) return {'product_id': product.id} # ------------------------------------------------------------------ # SKU Sync # ------------------------------------------------------------------ def action_set_wc_sku(self, sku): """Set WC product SKU.""" self.ensure_one() if not self.instance_id: return client = self.instance_id._get_client() client.update_product(self.woo_product_id, {'sku': sku}) self.woo_sku = sku self.instance_id._log_sync( 'product', 'odoo_to_woo', self.woo_product_name, 'success', 'WC SKU set to %s' % sku, ) def action_push_sku_to_odoo(self): """Copy WC SKU to Odoo internal reference.""" for rec in self: if rec.product_id and rec.woo_sku: rec.product_id.default_code = rec.woo_sku rec.instance_id._log_sync( 'product', 'woo_to_odoo', rec.product_id.name, 'success', 'Odoo SKU set from WC: %s' % rec.woo_sku, ) def action_push_sku_to_wc(self): """Copy Odoo internal reference to WC SKU.""" for rec in self: if rec.product_id and rec.instance_id: sku = rec.product_id.default_code or '' client = rec.instance_id._get_client() client.update_product(rec.woo_product_id, {'sku': sku}) rec.woo_sku = sku rec.instance_id._log_sync( 'product', 'odoo_to_woo', rec.product_id.name, 'success', 'WC SKU set from Odoo: %s' % sku, ) # ------------------------------------------------------------------ # Image Sync (Task 22) # ------------------------------------------------------------------ def action_sync_images(self): """Sync product images between Odoo and WooCommerce.""" for pm in self: if not pm.sync_images or not pm.product_id or pm.state != 'mapped': continue try: pm._sync_images_single() except Exception as e: _logger.error( "Image sync failed for %s (WC#%s): %s", pm.product_id.display_name, pm.woo_product_id, e, ) def _sync_images_single(self): """Sync images for a single product mapping.""" self.ensure_one() client = self.instance_id._get_client() wc_product = client.get_product(self.woo_product_id) wc_images = wc_product.get('images', []) # Get Odoo product image hash odoo_image = self.product_id.image_1920 odoo_hash = '' if odoo_image: odoo_hash = hashlib.md5(base64.b64decode(odoo_image)).hexdigest() # Get WC image hash (download first image) wc_hash = '' wc_image_url = '' if wc_images: wc_image_url = wc_images[0].get('src', '') if wc_image_url: try: resp = requests.get(wc_image_url, timeout=15) if resp.status_code == 200: wc_hash = hashlib.md5(resp.content).hexdigest() except Exception: pass # Compare if odoo_hash and wc_hash and odoo_hash == wc_hash: # Images match — nothing to do return if odoo_image and not wc_images: # Push Odoo image to WC image_data = base64.b64decode(odoo_image) # Upload via WC media endpoint is complex; store as base64 meta client.update_product(self.woo_product_id, { 'images': [{'src': '', 'name': self.product_id.name}], }) _logger.info("Image push for %s — WC images updated", self.product_id.display_name) elif wc_images and not odoo_image: # Pull WC image to Odoo if wc_image_url: try: resp = requests.get(wc_image_url, timeout=15) if resp.status_code == 200: self.product_id.image_1920 = base64.b64encode(resp.content) except Exception as e: _logger.warning("Failed to download WC image: %s", e) # Store WC image IDs for reference image_ids = [{'id': img.get('id'), 'src': img.get('src', '')} for img in wc_images] self.woo_image_ids = json.dumps(image_ids) self.last_synced = fields.Datetime.now()