feat: full variable product support — create WC products with variants from Odoo
Detects Odoo product variants (product.template.attribute_line) and creates WC variable products with attributes and variations. Each variation gets its own price, SKU, image, stock, and tax class. Variant lines shown in wizard with include/exclude toggle. WC attributes and terms auto-created. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -85,6 +85,35 @@ class WooApiClient:
|
|||||||
def create_product(self, data):
|
def create_product(self, data):
|
||||||
return self.post('products', 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
|
# Order endpoints
|
||||||
|
|
||||||
def get_orders(self, page=1, per_page=100, **kwargs):
|
def get_orders(self, page=1, per_page=100, **kwargs):
|
||||||
|
|||||||
@@ -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_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_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_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
|
||||||
|
|||||||
|
@@ -11,6 +11,21 @@ from ..lib.image_processor import ImageProcessor
|
|||||||
_logger = logging.getLogger(__name__)
|
_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):
|
class WooProductCreateWizard(models.TransientModel):
|
||||||
_name = 'woo.product.create.wizard'
|
_name = 'woo.product.create.wizard'
|
||||||
_description = 'Create Product in WooCommerce'
|
_description = 'Create Product in WooCommerce'
|
||||||
@@ -68,8 +83,32 @@ class WooProductCreateWizard(models.TransientModel):
|
|||||||
meta_description = fields.Text(string='Meta Description')
|
meta_description = fields.Text(string='Meta Description')
|
||||||
seo_keywords = fields.Char(string='SEO Focus Keywords')
|
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
|
# 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 ---
|
# --- Onchange / Defaults ---
|
||||||
|
|
||||||
@api.onchange('odoo_product_id')
|
@api.onchange('odoo_product_id')
|
||||||
@@ -89,6 +128,29 @@ class WooProductCreateWizard(models.TransientModel):
|
|||||||
if product.image_1920:
|
if product.image_1920:
|
||||||
self.image_1 = 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')
|
@api.onchange('odoo_category_id')
|
||||||
def _onchange_category(self):
|
def _onchange_category(self):
|
||||||
"""Auto-map Odoo category to WC category using woo.category.map."""
|
"""Auto-map Odoo category to WC category using woo.category.map."""
|
||||||
@@ -467,33 +529,175 @@ class WooProductCreateWizard(models.TransientModel):
|
|||||||
if wc_images:
|
if wc_images:
|
||||||
wc_data['images'] = wc_images
|
wc_data['images'] = wc_images
|
||||||
|
|
||||||
# --- Create WC product ---
|
# --- Handle variable vs simple product ---
|
||||||
try:
|
if self.has_variants:
|
||||||
wc_product = client.create_product(wc_data)
|
tmpl = self.product_template_id
|
||||||
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 ---
|
# Build WC attributes from Odoo attribute lines
|
||||||
self.env['woo.product.map'].create({
|
wc_attributes = []
|
||||||
'instance_id': inst.id,
|
for attr_line in tmpl.attribute_line_ids:
|
||||||
'product_id': odoo_product.id,
|
attr_name = attr_line.attribute_id.name
|
||||||
'woo_product_id': wc_product_id,
|
attr_values = attr_line.value_ids.mapped('name')
|
||||||
'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(
|
# Find or create WC attribute
|
||||||
'product', 'odoo_to_woo', odoo_product.name, 'success',
|
wc_attr = self._find_or_create_wc_attribute(client, attr_name)
|
||||||
'Created WC product #%s' % wc_product_id,
|
|
||||||
)
|
# 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
|
# Close wizard and show success
|
||||||
return {
|
return {
|
||||||
@@ -501,9 +705,42 @@ class WooProductCreateWizard(models.TransientModel):
|
|||||||
'tag': 'display_notification',
|
'tag': 'display_notification',
|
||||||
'params': {
|
'params': {
|
||||||
'title': 'Product Created',
|
'title': 'Product Created',
|
||||||
'message': 'Product "%s" created in WooCommerce successfully!' % wc_data['name'],
|
'message': success_msg,
|
||||||
'type': 'success',
|
'type': 'success',
|
||||||
'sticky': False,
|
'sticky': False,
|
||||||
'next': {'type': 'ir.actions.act_window_close'},
|
'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,
|
||||||
|
})
|
||||||
|
|||||||
@@ -37,6 +37,27 @@
|
|||||||
<field name="wc_category_name" readonly="1"/>
|
<field name="wc_category_name" readonly="1"/>
|
||||||
</group>
|
</group>
|
||||||
</group>
|
</group>
|
||||||
|
<!-- Variant info (visible when product has variants) -->
|
||||||
|
<div invisible="not has_variants" class="mt-3">
|
||||||
|
<separator string="Product Variants"/>
|
||||||
|
<div class="alert alert-info" role="alert">
|
||||||
|
This product has <field name="variant_count" readonly="1" class="d-inline"/> variants.
|
||||||
|
They will be created as WooCommerce variations.
|
||||||
|
</div>
|
||||||
|
<field name="variant_line_ids">
|
||||||
|
<list editable="bottom">
|
||||||
|
<field name="include" widget="boolean_toggle"/>
|
||||||
|
<field name="variant_name" readonly="1"/>
|
||||||
|
<field name="attribute_values" readonly="1"/>
|
||||||
|
<field name="sku"/>
|
||||||
|
<field name="sale_price"/>
|
||||||
|
<field name="cost_price"/>
|
||||||
|
<field name="image" widget="image" options="{'size': [64, 64]}"/>
|
||||||
|
</list>
|
||||||
|
</field>
|
||||||
|
</div>
|
||||||
|
<field name="has_variants" invisible="1"/>
|
||||||
|
<field name="product_template_id" invisible="1"/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- ========== STEP 2: Images ========== -->
|
<!-- ========== STEP 2: Images ========== -->
|
||||||
|
|||||||
Reference in New Issue
Block a user