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:
@@ -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
|
||||
],
|
||||
|
||||
@@ -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
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@@ -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
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user