diff --git a/fusion-woo-odoo/fusion_woocommerce/__manifest__.py b/fusion-woo-odoo/fusion_woocommerce/__manifest__.py index c55facda..e1ae93d9 100644 --- a/fusion-woo-odoo/fusion_woocommerce/__manifest__.py +++ b/fusion-woo-odoo/fusion_woocommerce/__manifest__.py @@ -34,6 +34,7 @@ 'wizard/woo_product_fetch_views.xml', 'wizard/woo_product_create_views.xml', 'wizard/woo_category_filter_views.xml', + 'wizard/woo_variant_push_views.xml', ], 'assets': { 'web.assets_backend': [ 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 2c1f34b1..08fa3b3a 100644 --- a/fusion-woo-odoo/fusion_woocommerce/security/ir.model.access.csv +++ b/fusion-woo-odoo/fusion_woocommerce/security/ir.model.access.csv @@ -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_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_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 diff --git a/fusion-woo-odoo/fusion_woocommerce/static/src/js/product_mapping.js b/fusion-woo-odoo/fusion_woocommerce/static/src/js/product_mapping.js index 5201d41f..5c779696 100644 --- a/fusion-woo-odoo/fusion_woocommerce/static/src/js/product_mapping.js +++ b/fusion-woo-odoo/fusion_woocommerce/static/src/js/product_mapping.js @@ -860,22 +860,24 @@ export class ProductMapping extends Component { // ------------------------------------------------------------------------- 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; + if (!this.state.instanceId) { + this.notification.add("Select an instance first.", { type: "warning" }); + return; } + 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(); + }, + }); } // ------------------------------------------------------------------------- diff --git a/fusion-woo-odoo/fusion_woocommerce/wizard/__init__.py b/fusion-woo-odoo/fusion_woocommerce/wizard/__init__.py index 286a6c1f..5e42535e 100644 --- a/fusion-woo-odoo/fusion_woocommerce/wizard/__init__.py +++ b/fusion-woo-odoo/fusion_woocommerce/wizard/__init__.py @@ -2,3 +2,4 @@ from . import woo_setup_wizard from . import woo_product_fetch from . import woo_product_create from . import woo_category_filter +from . import woo_variant_push diff --git a/fusion-woo-odoo/fusion_woocommerce/wizard/woo_variant_push.py b/fusion-woo-odoo/fusion_woocommerce/wizard/woo_variant_push.py new file mode 100644 index 00000000..910b66f7 --- /dev/null +++ b/fusion-woo-odoo/fusion_woocommerce/wizard/woo_variant_push.py @@ -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) diff --git a/fusion-woo-odoo/fusion_woocommerce/wizard/woo_variant_push_views.xml b/fusion-woo-odoo/fusion_woocommerce/wizard/woo_variant_push_views.xml new file mode 100644 index 00000000..113e758c --- /dev/null +++ b/fusion-woo-odoo/fusion_woocommerce/wizard/woo_variant_push_views.xml @@ -0,0 +1,55 @@ + + + + + woo.variant.push.wizard.form + woo.variant.push.wizard + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+ +