feat: push variants button on mapped products — convert simple→variable in WC

When a mapped product is simple on WC but has variants in Odoo, a purple
"N variants" button appears in the Variants column. Clicking it:
1. Converts the WC product from simple to variable
2. Creates WC attributes and terms from Odoo attribute lines
3. Creates a WC variation for each Odoo variant with price/SKU/stock/tax/image
4. Creates woo.product.map records for each variation

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsinghpal
2026-04-01 17:52:37 -04:00
parent a3fa1ced16
commit a0ad52fe46
4 changed files with 212 additions and 0 deletions

View File

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

View File

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

View File

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

View File

@@ -155,6 +155,7 @@
<th>Cost</th>
<th>Margin %</th>
<th>Instance</th>
<th>Variants</th>
<th>Price Sync</th>
<th>Inventory Sync</th>
</tr>
@@ -280,6 +281,21 @@
<t t-else="" t-esc="this.formatMargin(p.odoo_cost, p.odoo_price)"/>
</td>
<td><t t-esc="p.instance_name"/></td>
<td class="text-center">
<t t-if="p.needs_variant_push">
<button class="woo-btn woo-btn-primary woo-btn-sm"
t-on-click.stop="() => this.pushVariantsToWC(p.id)"
title="Push Odoo variants to WooCommerce">
<i class="fa fa-sitemap me-1"/>
<t t-esc="p.odoo_variant_count"/> variants
</button>
</t>
<t t-elif="p.odoo_variant_count > 1 and !p.is_variation">
<span class="woo-badge woo-badge-mapped">
<t t-esc="p.odoo_variant_count"/> synced
</span>
</t>
</td>
<td>
<input type="checkbox"
t-att-checked="p.sync_price"