diff --git a/fusion-woo-odoo/fusion_woocommerce/lib/woo_api_client.py b/fusion-woo-odoo/fusion_woocommerce/lib/woo_api_client.py index f34ee860..1971692b 100644 --- a/fusion-woo-odoo/fusion_woocommerce/lib/woo_api_client.py +++ b/fusion-woo-odoo/fusion_woocommerce/lib/woo_api_client.py @@ -85,6 +85,35 @@ class WooApiClient: def create_product(self, data): return self.post('products', data) + # Attribute endpoints + + def get_product_attributes(self): + return self.get('products/attributes', params={'per_page': 100}) + + def create_product_attribute(self, data): + return self.post('products/attributes', data) + + def get_attribute_terms(self, attribute_id, page=1, per_page=100): + return self.get(f'products/attributes/{attribute_id}/terms', params={'page': page, 'per_page': per_page}) + + def create_attribute_term(self, attribute_id, data): + return self.post(f'products/attributes/{attribute_id}/terms', data) + + # Variation endpoints + + def create_product_variation(self, product_id, data): + return self.post(f'products/{product_id}/variations', data) + + def update_product_variation(self, product_id, variation_id, data): + return self.put(f'products/{product_id}/variations/{variation_id}', data) + + def delete_product_variation(self, product_id, variation_id): + return self.delete(f'products/{product_id}/variations/{variation_id}') + + def batch_create_variations(self, product_id, variations_data): + """Create multiple variations at once using WC batch endpoint.""" + return self.post(f'products/{product_id}/variations/batch', {'create': variations_data}) + # Order endpoints def get_orders(self, page=1, per_page=100, **kwargs): diff --git a/fusion-woo-odoo/fusion_woocommerce/security/ir.model.access.csv b/fusion-woo-odoo/fusion_woocommerce/security/ir.model.access.csv index 5beb7f3f..2c1f34b1 100644 --- a/fusion-woo-odoo/fusion_woocommerce/security/ir.model.access.csv +++ b/fusion-woo-odoo/fusion_woocommerce/security/ir.model.access.csv @@ -29,3 +29,4 @@ access_woo_setup_wizard_manager,woo.setup.wizard.manager,model_woo_setup_wizard, access_woo_product_fetch_manager,woo.product.fetch.manager,model_woo_product_fetch,fusion_woocommerce.group_woo_manager,1,1,1,1 access_woo_product_create_wizard_manager,woo.product.create.wizard.manager,model_woo_product_create_wizard,fusion_woocommerce.group_woo_manager,1,1,1,1 access_woo_category_filter_manager,woo.category.filter.manager,model_woo_category_filter,group_woo_manager,1,1,1,1 +access_woo_product_create_variant_line_manager,woo.product.create.variant.line.manager,model_woo_product_create_variant_line,fusion_woocommerce.group_woo_manager,1,1,1,1 diff --git a/fusion-woo-odoo/fusion_woocommerce/wizard/woo_product_create.py b/fusion-woo-odoo/fusion_woocommerce/wizard/woo_product_create.py index 992d62b6..724e4940 100644 --- a/fusion-woo-odoo/fusion_woocommerce/wizard/woo_product_create.py +++ b/fusion-woo-odoo/fusion_woocommerce/wizard/woo_product_create.py @@ -11,6 +11,21 @@ from ..lib.image_processor import ImageProcessor _logger = logging.getLogger(__name__) +class WooProductCreateVariantLine(models.TransientModel): + _name = 'woo.product.create.variant.line' + _description = 'Product Creation Variant Line' + + wizard_id = fields.Many2one('woo.product.create.wizard', ondelete='cascade') + product_id = fields.Many2one('product.product', string='Variant', readonly=True) + variant_name = fields.Char(string='Variant', readonly=True) + attribute_values = fields.Char(string='Attributes', readonly=True) + sku = fields.Char(string='SKU') + sale_price = fields.Float(string='Price', digits='Product Price') + cost_price = fields.Float(string='Cost', digits='Product Price') + image = fields.Binary(string='Image') + include = fields.Boolean(string='Include', default=True) + + class WooProductCreateWizard(models.TransientModel): _name = 'woo.product.create.wizard' _description = 'Create Product in WooCommerce' @@ -68,8 +83,32 @@ class WooProductCreateWizard(models.TransientModel): meta_description = fields.Text(string='Meta Description') seo_keywords = fields.Char(string='SEO Focus Keywords') + # Variant detection + has_variants = fields.Boolean(compute='_compute_has_variants') + variant_count = fields.Integer(compute='_compute_has_variants') + product_template_id = fields.Many2one('product.template', compute='_compute_has_variants') + + # Variant line display + variant_line_ids = fields.One2many('woo.product.create.variant.line', 'wizard_id') + # Step 4 is review - uses fields above + # --- Variant Detection --- + + @api.depends('odoo_product_id') + def _compute_has_variants(self): + for rec in self: + if rec.odoo_product_id: + tmpl = rec.odoo_product_id.product_tmpl_id + variants = tmpl.product_variant_ids + rec.product_template_id = tmpl.id + rec.has_variants = len(variants) > 1 + rec.variant_count = len(variants) + else: + rec.product_template_id = False + rec.has_variants = False + rec.variant_count = 0 + # --- Onchange / Defaults --- @api.onchange('odoo_product_id') @@ -89,6 +128,29 @@ class WooProductCreateWizard(models.TransientModel): if product.image_1920: self.image_1 = product.image_1920 + # Populate variant lines + tmpl = product.product_tmpl_id + variants = tmpl.product_variant_ids + if len(variants) > 1: + lines = [] + for variant in variants: + attr_values = ', '.join( + variant.product_template_attribute_value_ids.mapped('name') + ) + lines.append((0, 0, { + 'product_id': variant.id, + 'variant_name': variant.display_name, + 'attribute_values': attr_values, + 'sku': variant.default_code or '', + 'sale_price': variant.list_price, + 'cost_price': variant.standard_price, + 'image': variant.image_variant_1920 or False, + 'include': True, + })) + self.variant_line_ids = lines + else: + self.variant_line_ids = [(5, 0, 0)] + @api.onchange('odoo_category_id') def _onchange_category(self): """Auto-map Odoo category to WC category using woo.category.map.""" @@ -467,33 +529,175 @@ class WooProductCreateWizard(models.TransientModel): if wc_images: wc_data['images'] = wc_images - # --- Create WC product --- - try: - wc_product = client.create_product(wc_data) - wc_product_id = wc_product['id'] - wc_permalink = wc_product.get('permalink', '') - except Exception as e: - raise UserError("Failed to create WooCommerce product: %s" % str(e)) + # --- Handle variable vs simple product --- + if self.has_variants: + tmpl = self.product_template_id - # --- Create product mapping --- - self.env['woo.product.map'].create({ - 'instance_id': inst.id, - 'product_id': odoo_product.id, - 'woo_product_id': wc_product_id, - 'woo_product_name': wc_data['name'], - 'woo_sku': self.sku or '', - 'woo_regular_price': self.sale_price, - 'woo_sale_price': 0.0, - 'woo_permalink': wc_permalink, - 'woo_product_type': 'simple', - 'state': 'mapped', - 'company_id': inst.company_id.id, - }) + # 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') - inst._log_sync( - 'product', 'odoo_to_woo', odoo_product.name, 'success', - 'Created WC product #%s' % wc_product_id, - ) + # 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, + }) + + wc_data['type'] = 'variable' + wc_data['attributes'] = wc_attributes + # Remove regular_price for variable products (set on variations) + wc_data.pop('regular_price', None) + + # Create the parent product + try: + wc_product = client.create_product(wc_data) + wc_product_id = wc_product['id'] + wc_permalink = wc_product.get('permalink', '') + except Exception as e: + raise UserError("Failed to create WooCommerce variable product: %s" % str(e)) + + # Create parent product map + self.env['woo.product.map'].create({ + 'instance_id': inst.id, + 'product_id': odoo_product.id, + 'woo_product_id': wc_product_id, + 'woo_product_name': wc_data['name'], + 'woo_sku': self.sku or '', + 'woo_regular_price': 0, + 'woo_sale_price': 0, + 'woo_permalink': wc_permalink, + 'woo_product_type': 'variable', + 'state': 'mapped', + 'company_id': inst.company_id.id, + }) + + # Create variations + variation_count = 0 + for line in self.variant_line_ids: + if not line.include: + continue + + variant = line.product_id + # 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(line.sale_price), + 'sku': line.sku or '', + 'attributes': var_attributes, + 'manage_stock': True, + 'stock_quantity': int(variant.qty_available), + } + + # Upload variant image if present + if line.image: + try: + img_bytes = base64.b64decode( + line.image if isinstance(line.image, str) else line.image.decode('utf-8') + ) + import requests as req + wp_url = inst.url.rstrip('/') + upload_url = f"{wp_url}/wp-json/wp/v2/media" + var_filename = f"variant_{line.sku or variant.id}.jpg" + headers = { + 'Content-Disposition': f'attachment; filename="{var_filename}"', + 'Content-Type': 'image/jpeg', + } + resp = req.post( + upload_url, + auth=(inst.consumer_key, inst.consumer_secret), + headers=headers, + 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_e: + _logger.warning("Variant image upload failed for %s: %s", line.variant_name, img_e) + + # Tax class + if self.wc_tax_class: + var_data['tax_class'] = self.wc_tax_class + + try: + wc_variation = client.create_product_variation(wc_product_id, var_data) + + # Create variation product map + self.env['woo.product.map'].create({ + 'instance_id': inst.id, + 'product_id': variant.id, + 'woo_product_id': wc_variation['id'], + 'woo_product_name': line.variant_name, + 'woo_sku': line.sku or '', + 'woo_regular_price': line.sale_price, + 'woo_sale_price': 0, + 'woo_permalink': wc_permalink, + 'woo_product_type': 'simple', + 'woo_parent_id': wc_product_id, + 'is_variation': True, + 'state': 'mapped', + 'company_id': inst.company_id.id, + }) + variation_count += 1 + except Exception as e: + _logger.error("Failed to create variation for %s: %s", line.variant_name, e) + + inst._log_sync( + 'product', 'odoo_to_woo', tmpl.name, 'success', + 'Created variable product with %d variations' % variation_count, + ) + + success_msg = 'Variable product "%s" created with %d variations!' % (wc_data['name'], variation_count) + else: + # --- Simple product creation --- + try: + wc_product = client.create_product(wc_data) + wc_product_id = wc_product['id'] + wc_permalink = wc_product.get('permalink', '') + except Exception as e: + raise UserError("Failed to create WooCommerce product: %s" % str(e)) + + # Create product mapping + self.env['woo.product.map'].create({ + 'instance_id': inst.id, + 'product_id': odoo_product.id, + 'woo_product_id': wc_product_id, + 'woo_product_name': wc_data['name'], + 'woo_sku': self.sku or '', + 'woo_regular_price': self.sale_price, + 'woo_sale_price': 0.0, + 'woo_permalink': wc_permalink, + 'woo_product_type': 'simple', + 'state': 'mapped', + 'company_id': inst.company_id.id, + }) + + inst._log_sync( + 'product', 'odoo_to_woo', odoo_product.name, 'success', + 'Created WC product #%s' % wc_product_id, + ) + + success_msg = 'Product "%s" created in WooCommerce successfully!' % wc_data['name'] # Close wizard and show success return { @@ -501,9 +705,42 @@ class WooProductCreateWizard(models.TransientModel): 'tag': 'display_notification', 'params': { 'title': 'Product Created', - 'message': 'Product "%s" created in WooCommerce successfully!' % wc_data['name'], + 'message': success_msg, 'type': 'success', 'sticky': False, 'next': {'type': 'ir.actions.act_window_close'}, }, } + + # --- Variant Helper Methods --- + + 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 + # Create new attribute + 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 + # Create new term + return client.create_attribute_term(attr_id, { + 'name': term_name, + }) diff --git a/fusion-woo-odoo/fusion_woocommerce/wizard/woo_product_create_views.xml b/fusion-woo-odoo/fusion_woocommerce/wizard/woo_product_create_views.xml index 32d9ac42..ae8b7591 100644 --- a/fusion-woo-odoo/fusion_woocommerce/wizard/woo_product_create_views.xml +++ b/fusion-woo-odoo/fusion_woocommerce/wizard/woo_product_create_views.xml @@ -37,6 +37,27 @@ + +
+ + + + + + + + + + + + + +
+ +