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) <noreply@anthropic.com>
This commit is contained in:
@@ -158,7 +158,8 @@ class WooProductSearchController(http.Controller):
|
|||||||
'odoo_product_name': m.product_id.name if m.product_id else '',
|
'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_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,
|
'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_price': m.sync_price,
|
||||||
'sync_inventory': m.sync_inventory,
|
'sync_inventory': m.sync_inventory,
|
||||||
'instance_id': m.instance_id.id if m.instance_id else False,
|
'instance_id': m.instance_id.id if m.instance_id else False,
|
||||||
|
|||||||
@@ -196,9 +196,14 @@ class WooInstance(models.Model):
|
|||||||
match_state = 'mapped'
|
match_state = 'mapped'
|
||||||
auto_matched += 1
|
auto_matched += 1
|
||||||
|
|
||||||
wc_price = 0.0
|
wc_regular_price = 0.0
|
||||||
|
wc_sale_price = 0.0
|
||||||
try:
|
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):
|
except (ValueError, TypeError):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@@ -210,7 +215,8 @@ class WooInstance(models.Model):
|
|||||||
'woo_product_id': wc_id,
|
'woo_product_id': wc_id,
|
||||||
'woo_product_name': wc_name,
|
'woo_product_name': wc_name,
|
||||||
'woo_sku': wc_sku,
|
'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_permalink': wc_permalink,
|
||||||
'woo_product_type': wc_type if wc_type in ('simple', 'variable', 'grouped', 'external') else 'simple',
|
'woo_product_type': wc_type if wc_type in ('simple', 'variable', 'grouped', 'external') else 'simple',
|
||||||
'state': match_state,
|
'state': match_state,
|
||||||
@@ -249,9 +255,14 @@ class WooInstance(models.Model):
|
|||||||
var_state = 'mapped'
|
var_state = 'mapped'
|
||||||
auto_matched += 1
|
auto_matched += 1
|
||||||
|
|
||||||
var_price = 0.0
|
var_regular = 0.0
|
||||||
|
var_sale = 0.0
|
||||||
try:
|
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):
|
except (ValueError, TypeError):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@@ -261,7 +272,8 @@ class WooInstance(models.Model):
|
|||||||
'woo_product_id': var_id,
|
'woo_product_id': var_id,
|
||||||
'woo_product_name': var_name,
|
'woo_product_name': var_name,
|
||||||
'woo_sku': var_sku,
|
'woo_sku': var_sku,
|
||||||
'woo_price': var_price,
|
'woo_regular_price': var_regular,
|
||||||
|
'woo_sale_price': var_sale,
|
||||||
'woo_product_type': 'simple',
|
'woo_product_type': 'simple',
|
||||||
'woo_parent_id': wc_id,
|
'woo_parent_id': wc_id,
|
||||||
'is_variation': True,
|
'is_variation': True,
|
||||||
@@ -284,7 +296,7 @@ class WooInstance(models.Model):
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
def action_refresh_prices(self):
|
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()
|
self.ensure_one()
|
||||||
if self.state != 'connected':
|
if self.state != 'connected':
|
||||||
raise UserError("Instance is not connected.")
|
raise UserError("Instance is not connected.")
|
||||||
@@ -297,13 +309,24 @@ class WooInstance(models.Model):
|
|||||||
for m in maps:
|
for m in maps:
|
||||||
try:
|
try:
|
||||||
wc_prod = client.get_product(m.woo_product_id)
|
wc_prod = client.get_product(m.woo_product_id)
|
||||||
wc_price = 0.0
|
regular = 0.0
|
||||||
|
sale = 0.0
|
||||||
try:
|
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):
|
except (ValueError, TypeError):
|
||||||
pass
|
pass
|
||||||
if wc_price != m.woo_price:
|
try:
|
||||||
m.woo_price = wc_price
|
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
|
updated += 1
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
_logger.warning("Failed to refresh price for WC#%s: %s", m.woo_product_id, e)
|
_logger.warning("Failed to refresh price for WC#%s: %s", m.woo_product_id, e)
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import logging
|
|||||||
import requests
|
import requests
|
||||||
|
|
||||||
from odoo import fields, models
|
from odoo import fields, models
|
||||||
|
from odoo.exceptions import UserError
|
||||||
|
|
||||||
_logger = logging.getLogger(__name__)
|
_logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -25,7 +26,8 @@ class WooProductMap(models.Model):
|
|||||||
('grouped', 'Grouped'),
|
('grouped', 'Grouped'),
|
||||||
('external', 'External'),
|
('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_permalink = fields.Char(string='WC Product URL')
|
||||||
woo_parent_id = fields.Integer()
|
woo_parent_id = fields.Integer()
|
||||||
is_variation = fields.Boolean()
|
is_variation = fields.Boolean()
|
||||||
@@ -49,27 +51,89 @@ class WooProductMap(models.Model):
|
|||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
def action_push_price_to_odoo(self):
|
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:
|
for rec in self:
|
||||||
if rec.product_id and rec.woo_price:
|
if not rec.product_id:
|
||||||
rec.product_id.list_price = rec.woo_price
|
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(
|
rec.instance_id._log_sync(
|
||||||
'product', 'woo_to_odoo', rec.product_id.name, 'success',
|
'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):
|
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:
|
for rec in self:
|
||||||
if rec.product_id and rec.instance_id:
|
if not rec.product_id or not rec.instance_id:
|
||||||
client = rec.instance_id._get_client()
|
continue
|
||||||
new_price = str(rec.product_id.list_price)
|
odoo_price = rec.product_id.list_price
|
||||||
client.update_product(rec.woo_product_id, {'regular_price': new_price})
|
wc_regular = rec.woo_regular_price or 0.0
|
||||||
rec.woo_price = rec.product_id.list_price
|
|
||||||
|
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(
|
rec.instance_id._log_sync(
|
||||||
'product', 'odoo_to_woo', rec.product_id.name, 'success',
|
'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)
|
# Image Sync (Task 22)
|
||||||
|
|||||||
@@ -605,6 +605,12 @@ html[style*="color-scheme: dark"] {
|
|||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Sale price highlight */
|
||||||
|
.woo-sale-price {
|
||||||
|
color: var(--woo-success);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
.woo-price-sync-col {
|
.woo-price-sync-col {
|
||||||
width: 60px;
|
width: 60px;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
|
|||||||
@@ -140,7 +140,8 @@
|
|||||||
<th>WooCommerce Product</th>
|
<th>WooCommerce Product</th>
|
||||||
<th>SKU</th>
|
<th>SKU</th>
|
||||||
<th>Odoo Product</th>
|
<th>Odoo Product</th>
|
||||||
<th>WC Price</th>
|
<th>WC Standard</th>
|
||||||
|
<th>WC Sale</th>
|
||||||
<th class="text-center"><i class="fa fa-exchange" title="Price Sync"/></th>
|
<th class="text-center"><i class="fa fa-exchange" title="Price Sync"/></th>
|
||||||
<th>Odoo Price</th>
|
<th>Odoo Price</th>
|
||||||
<th>Instance</th>
|
<th>Instance</th>
|
||||||
@@ -167,7 +168,13 @@
|
|||||||
</td>
|
</td>
|
||||||
<td><span class="woo-code"><t t-esc="p.woo_sku"/></span></td>
|
<td><span class="woo-code"><t t-esc="p.woo_sku"/></span></td>
|
||||||
<td><t t-esc="p.odoo_product_name"/></td>
|
<td><t t-esc="p.odoo_product_name"/></td>
|
||||||
<td class="text-end" t-esc="this.formatPrice(p.woo_price)"/>
|
<td class="text-end" t-esc="this.formatPrice(p.woo_regular_price)"/>
|
||||||
|
<td class="text-end">
|
||||||
|
<t t-if="p.woo_sale_price">
|
||||||
|
<span class="woo-sale-price" t-esc="this.formatPrice(p.woo_sale_price)"/>
|
||||||
|
</t>
|
||||||
|
<t t-else=""><span class="woo-text-muted">—</span></t>
|
||||||
|
</td>
|
||||||
<td class="text-center woo-price-sync-col">
|
<td class="text-center woo-price-sync-col">
|
||||||
<button class="woo-btn-icon" title="Push WC price to Odoo"
|
<button class="woo-btn-icon" title="Push WC price to Odoo"
|
||||||
t-on-click.stop="() => this.pushPriceToOdoo(p.id)">
|
t-on-click.stop="() => this.pushPriceToOdoo(p.id)">
|
||||||
|
|||||||
Reference in New Issue
Block a user