From 80f9ddd0d65be5bfa393f8adf1338159375c29f3 Mon Sep 17 00:00:00 2001 From: gsinghpal Date: Tue, 31 Mar 2026 23:19:01 -0400 Subject: [PATCH] feat: WC standard price + sale price with smart sync logic - Split woo_price into woo_regular_price and woo_sale_price - Sync to WC: if standard=0 sets regular_price, otherwise sets sale_price - Validation: standard price cannot be less than sale price - Both prices shown in mapping UI with sale price highlighted in green - Refresh Prices pulls both values from WC API Co-Authored-By: Claude Opus 4.6 (1M context) --- .../controllers/product_search.py | 3 +- .../fusion_woocommerce/models/woo_instance.py | 45 +++++++--- .../models/woo_product_map.py | 88 ++++++++++++++++--- .../static/src/css/woo_styles.css | 6 ++ .../static/src/xml/product_mapping.xml | 11 ++- 5 files changed, 127 insertions(+), 26 deletions(-) diff --git a/fusion-woo-odoo/fusion_woocommerce/controllers/product_search.py b/fusion-woo-odoo/fusion_woocommerce/controllers/product_search.py index 89d263e1..763a74cf 100644 --- a/fusion-woo-odoo/fusion_woocommerce/controllers/product_search.py +++ b/fusion-woo-odoo/fusion_woocommerce/controllers/product_search.py @@ -158,7 +158,8 @@ class WooProductSearchController(http.Controller): 'odoo_product_name': m.product_id.name if m.product_id else '', 'odoo_default_code': m.product_id.default_code or '' if m.product_id else '', 'odoo_price': m.product_id.list_price if m.product_id else 0.0, - 'woo_price': m.woo_price or 0.0, + 'woo_regular_price': m.woo_regular_price or 0.0, + 'woo_sale_price': m.woo_sale_price or 0.0, 'sync_price': m.sync_price, 'sync_inventory': m.sync_inventory, 'instance_id': m.instance_id.id if m.instance_id else False, diff --git a/fusion-woo-odoo/fusion_woocommerce/models/woo_instance.py b/fusion-woo-odoo/fusion_woocommerce/models/woo_instance.py index cf4129ff..a725ef58 100644 --- a/fusion-woo-odoo/fusion_woocommerce/models/woo_instance.py +++ b/fusion-woo-odoo/fusion_woocommerce/models/woo_instance.py @@ -196,9 +196,14 @@ class WooInstance(models.Model): match_state = 'mapped' auto_matched += 1 - wc_price = 0.0 + wc_regular_price = 0.0 + wc_sale_price = 0.0 try: - wc_price = float(wc_prod.get('regular_price') or wc_prod.get('price') or 0) + wc_regular_price = float(wc_prod.get('regular_price') or 0) + except (ValueError, TypeError): + pass + try: + wc_sale_price = float(wc_prod.get('sale_price') or 0) except (ValueError, TypeError): pass @@ -210,7 +215,8 @@ class WooInstance(models.Model): 'woo_product_id': wc_id, 'woo_product_name': wc_name, 'woo_sku': wc_sku, - 'woo_price': wc_price, + 'woo_regular_price': wc_regular_price, + 'woo_sale_price': wc_sale_price, 'woo_permalink': wc_permalink, 'woo_product_type': wc_type if wc_type in ('simple', 'variable', 'grouped', 'external') else 'simple', 'state': match_state, @@ -249,9 +255,14 @@ class WooInstance(models.Model): var_state = 'mapped' auto_matched += 1 - var_price = 0.0 + var_regular = 0.0 + var_sale = 0.0 try: - var_price = float(var.get('regular_price') or var.get('price') or 0) + var_regular = float(var.get('regular_price') or 0) + except (ValueError, TypeError): + pass + try: + var_sale = float(var.get('sale_price') or 0) except (ValueError, TypeError): pass @@ -261,7 +272,8 @@ class WooInstance(models.Model): 'woo_product_id': var_id, 'woo_product_name': var_name, 'woo_sku': var_sku, - 'woo_price': var_price, + 'woo_regular_price': var_regular, + 'woo_sale_price': var_sale, 'woo_product_type': 'simple', 'woo_parent_id': wc_id, 'is_variation': True, @@ -284,7 +296,7 @@ class WooInstance(models.Model): return True def action_refresh_prices(self): - """Refresh WC prices for all mapped products from WooCommerce API.""" + """Refresh WC standard + sale prices for all products from WooCommerce API.""" self.ensure_one() if self.state != 'connected': raise UserError("Instance is not connected.") @@ -297,13 +309,24 @@ class WooInstance(models.Model): for m in maps: try: wc_prod = client.get_product(m.woo_product_id) - wc_price = 0.0 + regular = 0.0 + sale = 0.0 try: - wc_price = float(wc_prod.get('regular_price') or wc_prod.get('price') or 0) + regular = float(wc_prod.get('regular_price') or 0) except (ValueError, TypeError): pass - if wc_price != m.woo_price: - m.woo_price = wc_price + try: + sale = float(wc_prod.get('sale_price') or 0) + except (ValueError, TypeError): + pass + changed = False + if abs(regular - (m.woo_regular_price or 0)) > 0.001: + m.woo_regular_price = regular + changed = True + if abs(sale - (m.woo_sale_price or 0)) > 0.001: + m.woo_sale_price = sale + changed = True + if changed: updated += 1 except Exception as e: _logger.warning("Failed to refresh price for WC#%s: %s", m.woo_product_id, e) diff --git a/fusion-woo-odoo/fusion_woocommerce/models/woo_product_map.py b/fusion-woo-odoo/fusion_woocommerce/models/woo_product_map.py index 24713d9e..0c379943 100644 --- a/fusion-woo-odoo/fusion_woocommerce/models/woo_product_map.py +++ b/fusion-woo-odoo/fusion_woocommerce/models/woo_product_map.py @@ -6,6 +6,7 @@ import logging import requests from odoo import fields, models +from odoo.exceptions import UserError _logger = logging.getLogger(__name__) @@ -25,7 +26,8 @@ class WooProductMap(models.Model): ('grouped', 'Grouped'), ('external', 'External'), ]) - woo_price = fields.Float(string='WC Price', digits='Product Price') + woo_regular_price = fields.Float(string='WC Standard Price', digits='Product Price') + woo_sale_price = fields.Float(string='WC Sale Price', digits='Product Price') woo_permalink = fields.Char(string='WC Product URL') woo_parent_id = fields.Integer() is_variation = fields.Boolean() @@ -49,27 +51,89 @@ class WooProductMap(models.Model): # ------------------------------------------------------------------ def action_push_price_to_odoo(self): - """Update Odoo product price from WC price.""" + """Update Odoo product price from WC sale price (or regular if no sale).""" for rec in self: - if rec.product_id and rec.woo_price: - rec.product_id.list_price = rec.woo_price + if not rec.product_id: + continue + # Use sale price if available, otherwise regular price + wc_price = rec.woo_sale_price if rec.woo_sale_price else rec.woo_regular_price + if wc_price: + rec.product_id.list_price = wc_price rec.instance_id._log_sync( 'product', 'woo_to_odoo', rec.product_id.name, 'success', - 'Price updated from WC: $%.2f' % rec.woo_price, + 'Odoo price updated from WC: $%.2f' % wc_price, ) def action_push_price_to_wc(self): - """Update WC product price from Odoo price.""" + """Push Odoo price to WC. + + Logic: + - If WC standard (regular) price is 0 or empty: set as regular_price + - If WC standard price exists: set as sale_price + - If WC standard price < Odoo price: error (standard can't be less than sale) + """ + errors = [] for rec in self: - if rec.product_id and rec.instance_id: - client = rec.instance_id._get_client() - new_price = str(rec.product_id.list_price) - client.update_product(rec.woo_product_id, {'regular_price': new_price}) - rec.woo_price = rec.product_id.list_price + if not rec.product_id or not rec.instance_id: + continue + odoo_price = rec.product_id.list_price + wc_regular = rec.woo_regular_price or 0.0 + + client = rec.instance_id._get_client() + update_data = {} + + if wc_regular < 0.01: + # No standard price set — push as regular_price + update_data = { + 'regular_price': str(odoo_price), + 'sale_price': '', + } + rec.woo_regular_price = odoo_price + rec.woo_sale_price = 0.0 + else: + # Standard price exists — push as sale_price + if wc_regular < odoo_price - 0.01: + # Standard price is less than the price we want to set as sale + errors.append( + '%s: WC standard price ($%.2f) is less than Odoo price ($%.2f). ' + 'Update the standard price first.' % (rec.woo_product_name, wc_regular, odoo_price) + ) + continue + update_data = { + 'sale_price': str(odoo_price), + } + rec.woo_sale_price = odoo_price + + try: + client.update_product(rec.woo_product_id, update_data) rec.instance_id._log_sync( 'product', 'odoo_to_woo', rec.product_id.name, 'success', - 'Price pushed to WC: $%.2f' % rec.product_id.list_price, + 'Price pushed to WC: $%.2f' % odoo_price, ) + except Exception as e: + errors.append('%s: %s' % (rec.woo_product_name, str(e))) + + if errors: + raise UserError('\n'.join(errors)) + + def action_set_regular_price(self, price): + """Set the WC standard (regular) price directly.""" + self.ensure_one() + if not self.instance_id: + return + client = self.instance_id._get_client() + # If there's a sale price, regular must be >= sale + if self.woo_sale_price and price < self.woo_sale_price - 0.01: + raise UserError( + 'Standard price ($%.2f) cannot be less than the current sale price ($%.2f).' + % (price, self.woo_sale_price) + ) + client.update_product(self.woo_product_id, {'regular_price': str(price)}) + self.woo_regular_price = price + self.instance_id._log_sync( + 'product', 'odoo_to_woo', self.woo_product_name, 'success', + 'Standard price set to $%.2f' % price, + ) # ------------------------------------------------------------------ # Image Sync (Task 22) diff --git a/fusion-woo-odoo/fusion_woocommerce/static/src/css/woo_styles.css b/fusion-woo-odoo/fusion_woocommerce/static/src/css/woo_styles.css index 476a84c0..3ed6f689 100644 --- a/fusion-woo-odoo/fusion_woocommerce/static/src/css/woo_styles.css +++ b/fusion-woo-odoo/fusion_woocommerce/static/src/css/woo_styles.css @@ -605,6 +605,12 @@ html[style*="color-scheme: dark"] { opacity: 1; } +/* Sale price highlight */ +.woo-sale-price { + color: var(--woo-success); + font-weight: 600; +} + .woo-price-sync-col { width: 60px; white-space: nowrap; diff --git a/fusion-woo-odoo/fusion_woocommerce/static/src/xml/product_mapping.xml b/fusion-woo-odoo/fusion_woocommerce/static/src/xml/product_mapping.xml index ab25497d..3ca68364 100644 --- a/fusion-woo-odoo/fusion_woocommerce/static/src/xml/product_mapping.xml +++ b/fusion-woo-odoo/fusion_woocommerce/static/src/xml/product_mapping.xml @@ -140,7 +140,8 @@ WooCommerce Product SKU Odoo Product - WC Price + WC Standard + WC Sale Odoo Price Instance @@ -167,7 +168,13 @@ - + + + + + + +