diff --git a/fusion-woo-odoo/fusion_woocommerce/controllers/product_search.py b/fusion-woo-odoo/fusion_woocommerce/controllers/product_search.py index 449cc65f..2e293a4d 100644 --- a/fusion-woo-odoo/fusion_woocommerce/controllers/product_search.py +++ b/fusion-woo-odoo/fusion_woocommerce/controllers/product_search.py @@ -232,6 +232,15 @@ class WooProductSearchController(http.Controller): 'sync_inventory': m.sync_inventory, 'instance_id': m.instance_id.id if m.instance_id else False, 'instance_name': m.instance_id.name if m.instance_id else '', + 'is_variation': m.is_variation, + 'odoo_variant_count': len(m.product_id.product_tmpl_id.product_variant_ids) if m.product_id else 0, + 'wc_is_simple': (m.woo_product_type or 'simple') == 'simple' and not m.is_variation, + 'needs_variant_push': ( + m.product_id + and not m.is_variation + and (m.woo_product_type or 'simple') == 'simple' + and len(m.product_id.product_tmpl_id.product_variant_ids) > 1 + ), } for m in maps ], diff --git a/fusion-woo-odoo/fusion_woocommerce/models/woo_product_map.py b/fusion-woo-odoo/fusion_woocommerce/models/woo_product_map.py index e6a1a577..e9321af0 100644 --- a/fusion-woo-odoo/fusion_woocommerce/models/woo_product_map.py +++ b/fusion-woo-odoo/fusion_woocommerce/models/woo_product_map.py @@ -155,6 +155,170 @@ class WooProductMap(models.Model): '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)) + + # Step 3: Create a WC variation for each Odoo variant + created = 0 + for variant in variants: + # Check if variation already mapped + existing = self.search([ + ('instance_id', '=', inst.id), + ('product_id', '=', variant.id), + ('is_variation', '=', True), + ], limit=1) + if existing: + continue + + # Build variation attributes + var_attributes = [] + for ptav in variant.product_template_attribute_value_ids: + var_attributes.append({ + 'name': ptav.attribute_id.name, + 'option': ptav.name, + }) + + 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 + if variant.image_variant_1920: + # Upload image + try: + import base64 + import requests as req + img_bytes = base64.b64decode(variant.image_variant_1920) + wp_url = inst.url.rstrip('/') + filename = (variant.default_code or 'variant') + '.jpg' + resp = req.post( + f"{wp_url}/wp-json/wp/v2/media", + auth=(inst.consumer_key, inst.consumer_secret), + headers={ + 'Content-Disposition': f'attachment; filename="{filename}"', + 'Content-Type': 'image/jpeg', + }, + data=img_bytes, + timeout=60, + ) + if resp.status_code in (200, 201): + media = resp.json() + var_data['image'] = {'id': media['id']} + except Exception as img_err: + _logger.warning("Variant image upload failed: %s", img_err) + + 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}) + # ------------------------------------------------------------------ # SKU Sync # ------------------------------------------------------------------ diff --git a/fusion-woo-odoo/fusion_woocommerce/static/src/js/product_mapping.js b/fusion-woo-odoo/fusion_woocommerce/static/src/js/product_mapping.js index cf6d222d..5201d41f 100644 --- a/fusion-woo-odoo/fusion_woocommerce/static/src/js/product_mapping.js +++ b/fusion-woo-odoo/fusion_woocommerce/static/src/js/product_mapping.js @@ -855,6 +855,29 @@ export class ProductMapping extends Component { } } + // ------------------------------------------------------------------------- + // Push variants to WC + // ------------------------------------------------------------------------- + + async pushVariantsToWC(mapId) { + try { + this.state.loading = true; + await rpc("/web/dataset/call_kw", { + model: "woo.product.map", + method: "action_push_variants_to_wc", + args: [[mapId]], + kwargs: {}, + }); + this.notification.add("Variants pushed to WooCommerce successfully.", { type: "success" }); + await this._refreshAll(); + } catch (err) { + console.error("[ProductMapping] pushVariantsToWC error:", err); + this.notification.add(err.message || "Failed to push variants.", { type: "danger" }); + } finally { + this.state.loading = false; + } + } + // ------------------------------------------------------------------------- // Bulk price sync // ------------------------------------------------------------------------- diff --git a/fusion-woo-odoo/fusion_woocommerce/static/src/xml/product_mapping.xml b/fusion-woo-odoo/fusion_woocommerce/static/src/xml/product_mapping.xml index 8cd5b220..cab12d4a 100644 --- a/fusion-woo-odoo/fusion_woocommerce/static/src/xml/product_mapping.xml +++ b/fusion-woo-odoo/fusion_woocommerce/static/src/xml/product_mapping.xml @@ -155,6 +155,7 @@