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 @@