feat: variant push wizard — review pricing, SKU, images before pushing
Replaced direct push with a wizard that shows all variants in an editable table. User can review/edit standard price, sale price, SKU, and image per variant before pushing. Include/exclude toggle per variant. Already synced variants shown for reference. Geo-tags images if configured. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -34,6 +34,7 @@
|
|||||||
'wizard/woo_product_fetch_views.xml',
|
'wizard/woo_product_fetch_views.xml',
|
||||||
'wizard/woo_product_create_views.xml',
|
'wizard/woo_product_create_views.xml',
|
||||||
'wizard/woo_category_filter_views.xml',
|
'wizard/woo_category_filter_views.xml',
|
||||||
|
'wizard/woo_variant_push_views.xml',
|
||||||
],
|
],
|
||||||
'assets': {
|
'assets': {
|
||||||
'web.assets_backend': [
|
'web.assets_backend': [
|
||||||
|
|||||||
@@ -30,3 +30,5 @@ access_woo_product_fetch_manager,woo.product.fetch.manager,model_woo_product_fet
|
|||||||
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
|
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
|
||||||
|
access_woo_variant_push_wizard_manager,woo.variant.push.wizard.manager,model_woo_variant_push_wizard,group_woo_manager,1,1,1,1
|
||||||
|
access_woo_variant_push_line_manager,woo.variant.push.line.manager,model_woo_variant_push_line,group_woo_manager,1,1,1,1
|
||||||
|
|||||||
|
@@ -860,22 +860,24 @@ export class ProductMapping extends Component {
|
|||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
async pushVariantsToWC(mapId) {
|
async pushVariantsToWC(mapId) {
|
||||||
try {
|
if (!this.state.instanceId) {
|
||||||
this.state.loading = true;
|
this.notification.add("Select an instance first.", { type: "warning" });
|
||||||
await rpc("/web/dataset/call_kw", {
|
return;
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
this.actionService.doAction({
|
||||||
|
type: 'ir.actions.act_window',
|
||||||
|
res_model: 'woo.variant.push.wizard',
|
||||||
|
views: [[false, 'form']],
|
||||||
|
target: 'new',
|
||||||
|
context: {
|
||||||
|
default_instance_id: this.state.instanceId,
|
||||||
|
default_product_map_id: mapId,
|
||||||
|
},
|
||||||
|
}, {
|
||||||
|
onClose: async () => {
|
||||||
|
await this._refreshAll();
|
||||||
|
},
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -2,3 +2,4 @@ from . import woo_setup_wizard
|
|||||||
from . import woo_product_fetch
|
from . import woo_product_fetch
|
||||||
from . import woo_product_create
|
from . import woo_product_create
|
||||||
from . import woo_category_filter
|
from . import woo_category_filter
|
||||||
|
from . import woo_variant_push
|
||||||
|
|||||||
244
fusion-woo-odoo/fusion_woocommerce/wizard/woo_variant_push.py
Normal file
244
fusion-woo-odoo/fusion_woocommerce/wizard/woo_variant_push.py
Normal file
@@ -0,0 +1,244 @@
|
|||||||
|
import base64
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from odoo import api, fields, models
|
||||||
|
from odoo.exceptions import UserError
|
||||||
|
|
||||||
|
from ..lib.image_processor import ImageProcessor
|
||||||
|
|
||||||
|
_logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class WooVariantPushWizard(models.TransientModel):
|
||||||
|
_name = 'woo.variant.push.wizard'
|
||||||
|
_description = 'Push Variants to WooCommerce'
|
||||||
|
|
||||||
|
instance_id = fields.Many2one('woo.instance', required=True)
|
||||||
|
product_map_id = fields.Many2one('woo.product.map', required=True, string='Product Mapping')
|
||||||
|
product_template_id = fields.Many2one('product.template', string='Product Template', readonly=True)
|
||||||
|
product_name = fields.Char(readonly=True)
|
||||||
|
woo_product_id = fields.Integer(readonly=True)
|
||||||
|
|
||||||
|
line_ids = fields.One2many('woo.variant.push.line', 'wizard_id', string='Variants')
|
||||||
|
|
||||||
|
@api.model
|
||||||
|
def default_get(self, fields_list):
|
||||||
|
res = super().default_get(fields_list)
|
||||||
|
map_id = self.env.context.get('default_product_map_id')
|
||||||
|
if map_id:
|
||||||
|
pm = self.env['woo.product.map'].browse(map_id)
|
||||||
|
if pm.exists() and pm.product_id:
|
||||||
|
tmpl = pm.product_id.product_tmpl_id
|
||||||
|
res['instance_id'] = pm.instance_id.id
|
||||||
|
res['product_template_id'] = tmpl.id
|
||||||
|
res['product_name'] = tmpl.name
|
||||||
|
res['woo_product_id'] = pm.woo_product_id
|
||||||
|
return res
|
||||||
|
|
||||||
|
@api.onchange('product_map_id')
|
||||||
|
def _onchange_product_map(self):
|
||||||
|
if self.product_map_id and self.product_map_id.product_id:
|
||||||
|
tmpl = self.product_map_id.product_id.product_tmpl_id
|
||||||
|
self.product_template_id = tmpl.id
|
||||||
|
self.product_name = tmpl.name
|
||||||
|
self.woo_product_id = self.product_map_id.woo_product_id
|
||||||
|
|
||||||
|
lines = []
|
||||||
|
for variant in tmpl.product_variant_ids:
|
||||||
|
# Check if already pushed
|
||||||
|
already_mapped = self.env['woo.product.map'].search([
|
||||||
|
('instance_id', '=', self.product_map_id.instance_id.id),
|
||||||
|
('product_id', '=', variant.id),
|
||||||
|
('is_variation', '=', True),
|
||||||
|
], limit=1)
|
||||||
|
|
||||||
|
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 '',
|
||||||
|
'regular_price': variant.list_price,
|
||||||
|
'sale_price': 0.0,
|
||||||
|
'cost_price': variant.standard_price,
|
||||||
|
'image': variant.image_variant_1920 or variant.image_1920 or False,
|
||||||
|
'include': not bool(already_mapped),
|
||||||
|
'already_synced': bool(already_mapped),
|
||||||
|
'wc_variation_id': already_mapped.woo_product_id if already_mapped else 0,
|
||||||
|
}))
|
||||||
|
self.line_ids = lines
|
||||||
|
|
||||||
|
def action_push(self):
|
||||||
|
"""Push selected variants to WooCommerce."""
|
||||||
|
self.ensure_one()
|
||||||
|
pm = self.product_map_id
|
||||||
|
inst = pm.instance_id
|
||||||
|
client = inst._get_client()
|
||||||
|
tmpl = self.product_template_id
|
||||||
|
|
||||||
|
lines_to_push = self.line_ids.filtered(lambda l: l.include and not l.already_synced)
|
||||||
|
if not lines_to_push:
|
||||||
|
raise UserError("No new variants selected to push.")
|
||||||
|
|
||||||
|
# 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')
|
||||||
|
|
||||||
|
wc_attr = pm._find_or_create_wc_attribute(client, attr_name)
|
||||||
|
wc_terms = []
|
||||||
|
for val_name in attr_values:
|
||||||
|
term = pm._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: Convert product to variable with attributes
|
||||||
|
try:
|
||||||
|
client.update_product(pm.woo_product_id, {
|
||||||
|
'type': 'variable',
|
||||||
|
'attributes': wc_attributes,
|
||||||
|
})
|
||||||
|
pm.woo_product_type = 'variable'
|
||||||
|
except Exception as e:
|
||||||
|
raise UserError("Failed to convert WC product to variable: %s" % str(e))
|
||||||
|
|
||||||
|
# Step 3: Create variations
|
||||||
|
created = 0
|
||||||
|
errors = []
|
||||||
|
for line in lines_to_push:
|
||||||
|
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.regular_price),
|
||||||
|
'sku': line.sku or '',
|
||||||
|
'attributes': var_attributes,
|
||||||
|
'manage_stock': True,
|
||||||
|
'stock_quantity': int(variant.qty_available),
|
||||||
|
}
|
||||||
|
|
||||||
|
# Sale price
|
||||||
|
if line.sale_price > 0:
|
||||||
|
var_data['sale_price'] = str(line.sale_price)
|
||||||
|
|
||||||
|
# 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
|
||||||
|
|
||||||
|
# Upload variant image
|
||||||
|
if line.image:
|
||||||
|
try:
|
||||||
|
img_data = line.image
|
||||||
|
if isinstance(img_data, bytes):
|
||||||
|
img_data = img_data.decode('utf-8')
|
||||||
|
|
||||||
|
# Geo-tag if configured
|
||||||
|
if inst.geo_lat and inst.geo_lng:
|
||||||
|
img_data = ImageProcessor.geo_tag_image(
|
||||||
|
img_data,
|
||||||
|
inst.geo_company_name,
|
||||||
|
inst.geo_company_address,
|
||||||
|
inst.geo_company_phone,
|
||||||
|
inst.geo_lat,
|
||||||
|
inst.geo_lng,
|
||||||
|
)
|
||||||
|
|
||||||
|
img_bytes = base64.b64decode(img_data)
|
||||||
|
import requests as req
|
||||||
|
wp_url = inst.url.rstrip('/')
|
||||||
|
filename = (line.sku or 'variant_%d' % variant.id) + '.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(pm.woo_product_id, var_data)
|
||||||
|
|
||||||
|
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.regular_price,
|
||||||
|
'woo_sale_price': line.sale_price,
|
||||||
|
'woo_permalink': pm.woo_permalink or '',
|
||||||
|
'woo_product_type': 'simple',
|
||||||
|
'woo_parent_id': pm.woo_product_id,
|
||||||
|
'is_variation': True,
|
||||||
|
'state': 'mapped',
|
||||||
|
'company_id': inst.company_id.id,
|
||||||
|
})
|
||||||
|
created += 1
|
||||||
|
except Exception as e:
|
||||||
|
errors.append('%s: %s' % (line.variant_name, str(e)))
|
||||||
|
_logger.error("Failed to create variation: %s", e)
|
||||||
|
|
||||||
|
inst._log_sync('product', 'odoo_to_woo', tmpl.name, 'success',
|
||||||
|
'Pushed %d variants to WC product #%s' % (created, pm.woo_product_id))
|
||||||
|
|
||||||
|
msg = 'Successfully pushed %d variant(s) to WooCommerce.' % created
|
||||||
|
if errors:
|
||||||
|
msg += '\n\nErrors:\n' + '\n'.join(errors)
|
||||||
|
|
||||||
|
return {
|
||||||
|
'type': 'ir.actions.client',
|
||||||
|
'tag': 'display_notification',
|
||||||
|
'params': {
|
||||||
|
'title': 'Variants Pushed',
|
||||||
|
'message': msg,
|
||||||
|
'type': 'success' if not errors else 'warning',
|
||||||
|
'sticky': bool(errors),
|
||||||
|
'next': {'type': 'ir.actions.act_window_close'},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class WooVariantPushLine(models.TransientModel):
|
||||||
|
_name = 'woo.variant.push.line'
|
||||||
|
_description = 'Variant Push Line'
|
||||||
|
|
||||||
|
wizard_id = fields.Many2one('woo.variant.push.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')
|
||||||
|
regular_price = fields.Float(string='Standard Price', digits='Product Price')
|
||||||
|
sale_price = fields.Float(string='Sale 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)
|
||||||
|
already_synced = fields.Boolean(string='Already Synced', readonly=True)
|
||||||
|
wc_variation_id = fields.Integer(string='WC Variation ID', readonly=True)
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<odoo>
|
||||||
|
|
||||||
|
<record id="woo_variant_push_wizard_form" model="ir.ui.view">
|
||||||
|
<field name="name">woo.variant.push.wizard.form</field>
|
||||||
|
<field name="model">woo.variant.push.wizard</field>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<form string="Push Variants to WooCommerce">
|
||||||
|
<sheet>
|
||||||
|
<field name="instance_id" invisible="1"/>
|
||||||
|
<field name="product_map_id" invisible="1"/>
|
||||||
|
|
||||||
|
<group>
|
||||||
|
<group>
|
||||||
|
<field name="product_name" string="Product"/>
|
||||||
|
<field name="woo_product_id" string="WC Product ID"/>
|
||||||
|
</group>
|
||||||
|
<group>
|
||||||
|
<field name="product_template_id" string="Odoo Template" readonly="1"/>
|
||||||
|
</group>
|
||||||
|
</group>
|
||||||
|
|
||||||
|
<separator string="Variants to Push"/>
|
||||||
|
<div class="alert alert-info" role="alert">
|
||||||
|
Review and edit each variant's pricing, SKU, and image before pushing.
|
||||||
|
Uncheck "Include" to skip a variant. Already synced variants are shown for reference.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<field name="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="regular_price" string="Standard Price"/>
|
||||||
|
<field name="sale_price"/>
|
||||||
|
<field name="cost_price" readonly="1"/>
|
||||||
|
<field name="image" widget="image" options="{'size': [48, 48]}"/>
|
||||||
|
<field name="already_synced" readonly="1" widget="boolean"/>
|
||||||
|
<field name="wc_variation_id" readonly="1" string="WC ID"
|
||||||
|
invisible="not already_synced"/>
|
||||||
|
</list>
|
||||||
|
</field>
|
||||||
|
</sheet>
|
||||||
|
<footer>
|
||||||
|
<button name="action_push" type="object"
|
||||||
|
string="Push Variants to WooCommerce" class="oe_highlight"
|
||||||
|
icon="fa-cloud-upload"/>
|
||||||
|
<button string="Cancel" class="btn-secondary" special="cancel"/>
|
||||||
|
</footer>
|
||||||
|
</form>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
</odoo>
|
||||||
Reference in New Issue
Block a user