Files
Odoo-Modules/fusion-woo-odoo/fusion_woocommerce/models/woo_instance.py
gsinghpal 0e84b97c89 fix: sync never changes product map state from mapped
Price conflicts and sync errors were setting woo.product.map state to
'conflict'/'error', making products disappear from the mapped list.
Conflicts are now tracked only in woo.conflict model. Map state stays
'mapped' as long as the product link exists.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 23:01:26 -04:00

1006 lines
40 KiB
Python

import base64
import hashlib
import json
import logging
import secrets
from odoo import api, fields, models
from odoo.exceptions import UserError
from ..lib.woo_api_client import WooApiClient
_logger = logging.getLogger(__name__)
class WooInstance(models.Model):
_name = 'woo.instance'
_description = 'WooCommerce Instance'
_inherit = ['mail.thread']
name = fields.Char(required=True, tracking=True)
url = fields.Char(required=True, tracking=True)
consumer_key = fields.Char(groups='base.group_system')
consumer_secret = fields.Char(groups='base.group_system')
webhook_secret = fields.Char(groups='base.group_system')
wc_api_version = fields.Char(default='wc/v3')
odoo_api_key = fields.Char(groups='base.group_system')
company_id = fields.Many2one(
'res.company', required=True, default=lambda self: self.env.company,
)
sync_interval = fields.Selection(
[('5', '5 Min'), ('15', '15 Min'), ('30', '30 Min'), ('60', '1 Hour')],
default='15',
)
sync_products = fields.Boolean(default=True)
sync_orders = fields.Boolean(default=True)
sync_invoices = fields.Boolean(default=True)
sync_inventory = fields.Boolean(default=True)
sync_customers = fields.Boolean(default=True)
default_warehouse_id = fields.Many2one('stock.warehouse')
notify_on_failure = fields.Boolean()
notify_user_ids = fields.Many2many('res.users')
state = fields.Selection(
[('draft', 'Draft'), ('connected', 'Connected'), ('error', 'Error')],
default='draft', tracking=True,
)
last_sync = fields.Datetime(readonly=True)
# Relational
product_map_ids = fields.One2many('woo.product.map', 'instance_id')
order_ids = fields.One2many('woo.order', 'instance_id')
customer_ids = fields.One2many('woo.customer', 'instance_id')
sync_log_ids = fields.One2many('woo.sync.log', 'instance_id')
# Computed
mapped_count = fields.Integer(compute='_compute_counts')
unmapped_count = fields.Integer(compute='_compute_counts')
error_count = fields.Integer(compute='_compute_counts')
@api.depends('product_map_ids.state', 'sync_log_ids')
def _compute_counts(self):
for rec in self:
rec.mapped_count = self.env['woo.product.map'].search_count([
('instance_id', '=', rec.id), ('state', '=', 'mapped'),
])
rec.unmapped_count = self.env['woo.product.map'].search_count([
('instance_id', '=', rec.id), ('state', '=', 'unmapped'),
])
yesterday = fields.Datetime.subtract(fields.Datetime.now(), hours=24)
rec.error_count = self.env['woo.sync.log'].search_count([
('instance_id', '=', rec.id),
('state', '=', 'failed'),
('create_date', '>=', yesterday),
])
def _get_client(self):
"""Return a WooApiClient instance for this WooCommerce connection."""
self.ensure_one()
if not self.consumer_key or not self.consumer_secret:
raise UserError("Consumer key and secret are required.")
return WooApiClient(
url=self.url,
consumer_key=self.consumer_key,
consumer_secret=self.consumer_secret,
api_version=self.wc_api_version or 'wc/v3',
)
def action_test_connection(self):
"""Test the WooCommerce connection and update state."""
self.ensure_one()
try:
client = self._get_client()
success, info = client.test_connection()
if success:
self.state = 'connected'
self.message_post(body=f"Connection successful. WooCommerce version: {info}")
else:
self.state = 'error'
self.message_post(body=f"Connection failed: {info}")
except Exception as exc:
self.state = 'error'
self.message_post(body=f"Connection error: {exc}")
_logger.exception("WooCommerce connection test failed for %s", self.name)
def action_generate_api_key(self):
"""Generate a random API key for the Odoo webhook endpoint."""
self.ensure_one()
self.odoo_api_key = secrets.token_urlsafe(32)
def action_view_product_maps(self):
"""Open product mapping list filtered by this instance."""
self.ensure_one()
return {
'type': 'ir.actions.act_window',
'name': 'Product Mapping',
'res_model': 'woo.product.map',
'view_mode': 'list,form',
'domain': [('instance_id', '=', self.id)],
'context': {'default_instance_id': self.id},
}
def action_view_sync_errors(self):
"""Open sync log filtered to errors for this instance."""
self.ensure_one()
twenty_four_ago = fields.Datetime.subtract(fields.Datetime.now(), hours=24)
return {
'type': 'ir.actions.act_window',
'name': 'Sync Errors (24h)',
'res_model': 'woo.sync.log',
'view_mode': 'list,form',
'domain': [
('instance_id', '=', self.id),
('state', '=', 'failed'),
('create_date', '>=', twenty_four_ago),
],
}
def _log_sync(self, sync_type, direction, record_ref, state, message=''):
"""Create a woo.sync.log record for this instance."""
self.ensure_one()
self.env['woo.sync.log'].sudo().create({
'instance_id': self.id,
'sync_type': sync_type,
'direction': direction,
'record_ref': record_ref,
'state': state,
'message': message,
'company_id': self.company_id.id,
})
# ------------------------------------------------------------------
# UI actions (called from OWL product mapping + dashboard)
# ------------------------------------------------------------------
def action_fetch_products(self):
"""Fetch products from WooCommerce and run auto-match."""
self.ensure_one()
if self.state != 'connected':
raise UserError("Please test the connection first.")
client = self._get_client()
ProductMap = self.env['woo.product.map']
page = 1
total_fetched = 0
auto_matched = 0
while True:
try:
products = client.get_products(page=page, per_page=100)
except Exception as e:
self._log_sync('product', 'woo_to_odoo', self.name, 'failed', str(e))
raise UserError("Failed to fetch products: %s" % str(e))
if not products:
break
for wc_prod in products:
wc_id = wc_prod.get('id')
# Skip if already mapped
existing = ProductMap.search([
('instance_id', '=', self.id),
('woo_product_id', '=', wc_id),
], limit=1)
if existing:
continue
wc_sku = wc_prod.get('sku', '') or ''
wc_name = wc_prod.get('name', '')
wc_type = wc_prod.get('type', 'simple')
# Try SKU match
odoo_product = False
match_state = 'unmapped'
if wc_sku:
odoo_product = self.env['product.product'].search([
('default_code', '=', wc_sku),
], limit=1)
if odoo_product:
match_state = 'mapped'
auto_matched += 1
wc_price = 0.0
try:
wc_price = float(wc_prod.get('regular_price') or wc_prod.get('price') or 0)
except (ValueError, TypeError):
pass
ProductMap.create({
'instance_id': self.id,
'product_id': odoo_product.id if odoo_product else False,
'woo_product_id': wc_id,
'woo_product_name': wc_name,
'woo_sku': wc_sku,
'woo_price': wc_price,
'woo_product_type': wc_type if wc_type in ('simple', 'variable', 'grouped', 'external') else 'simple',
'state': match_state,
'company_id': self.company_id.id,
})
total_fetched += 1
# Fetch variations for variable products
if wc_type == 'variable':
try:
var_page = 1
while True:
variations = client.get_product_variations(wc_id, page=var_page, per_page=100)
if not variations:
break
for var in variations:
var_id = var.get('id')
var_existing = ProductMap.search([
('instance_id', '=', self.id),
('woo_product_id', '=', var_id),
], limit=1)
if var_existing:
continue
var_sku = var.get('sku', '') or ''
var_name = wc_name + ' - ' + ', '.join(
[a.get('option', '') for a in var.get('attributes', [])]
)
var_product = False
var_state = 'unmapped'
if var_sku:
var_product = self.env['product.product'].search([
('default_code', '=', var_sku),
], limit=1)
if var_product:
var_state = 'mapped'
auto_matched += 1
var_price = 0.0
try:
var_price = float(var.get('regular_price') or var.get('price') or 0)
except (ValueError, TypeError):
pass
ProductMap.create({
'instance_id': self.id,
'product_id': var_product.id if var_product else False,
'woo_product_id': var_id,
'woo_product_name': var_name,
'woo_sku': var_sku,
'woo_price': var_price,
'woo_product_type': 'simple',
'woo_parent_id': wc_id,
'is_variation': True,
'state': var_state,
'company_id': self.company_id.id,
})
total_fetched += 1
var_page += 1
if len(variations) < 100:
break
except Exception:
_logger.warning("Failed to fetch variations for product %s", wc_id)
page += 1
if len(products) < 100:
break
self._log_sync('product', 'woo_to_odoo', self.name, 'success',
'Fetched %d products, auto-matched %d by SKU' % (total_fetched, auto_matched))
return True
def action_refresh_prices(self):
"""Refresh WC prices for all mapped products from WooCommerce API."""
self.ensure_one()
if self.state != 'connected':
raise UserError("Instance is not connected.")
client = self._get_client()
maps = self.env['woo.product.map'].search([
('instance_id', '=', self.id),
('woo_product_id', '>', 0),
])
updated = 0
for m in maps:
try:
wc_prod = client.get_product(m.woo_product_id)
wc_price = 0.0
try:
wc_price = float(wc_prod.get('regular_price') or wc_prod.get('price') or 0)
except (ValueError, TypeError):
pass
if wc_price != m.woo_price:
m.woo_price = wc_price
updated += 1
except Exception as e:
_logger.warning("Failed to refresh price for WC#%s: %s", m.woo_product_id, e)
self._log_sync('product', 'woo_to_odoo', self.name, 'success',
'Refreshed prices for %d products' % updated)
return True
def action_sync(self):
"""Manual sync trigger from the UI."""
self.ensure_one()
if self.state != 'connected':
raise UserError("Instance is not connected.")
try:
if self.sync_products:
self._sync_products()
if self.sync_orders:
self._sync_orders()
if self.sync_inventory:
self._sync_inventory()
if self.sync_customers:
self._sync_customers()
self.last_sync = fields.Datetime.now()
except Exception as e:
_logger.error("Manual sync failed for %s: %s", self.name, str(e))
self._log_sync('product', 'odoo_to_woo', self.name, 'failed', str(e))
raise UserError("Sync failed: %s" % str(e))
return True
# ------------------------------------------------------------------
# Cron entry points
# ------------------------------------------------------------------
@api.model
def _cron_sync_products(self):
"""Sync product prices and inventory for all connected instances."""
instances = self.search([('state', '=', 'connected'), ('sync_products', '=', True)])
for instance in instances:
try:
instance._sync_products()
except Exception as e:
_logger.error("Product sync failed for %s: %s", instance.name, str(e))
instance._log_sync('product', 'woo_to_odoo', instance.name, 'failed', str(e))
instance._notify_failure('product', str(e))
@api.model
def _cron_sync_orders(self):
"""Fetch new orders from all connected WooCommerce instances."""
instances = self.search([('state', '=', 'connected'), ('sync_orders', '=', True)])
for instance in instances:
try:
instance._sync_orders()
except Exception as e:
_logger.error("Order sync failed for %s: %s", instance.name, str(e))
instance._log_sync('order', 'woo_to_odoo', instance.name, 'failed', str(e))
instance._notify_failure('order', str(e))
@api.model
def _cron_sync_inventory(self):
"""Push inventory levels to WooCommerce for all connected instances."""
instances = self.search([('state', '=', 'connected'), ('sync_inventory', '=', True)])
for instance in instances:
try:
instance._sync_inventory()
except Exception as e:
_logger.error("Inventory sync failed for %s: %s", instance.name, str(e))
instance._log_sync('inventory', 'odoo_to_woo', instance.name, 'failed', str(e))
instance._notify_failure('inventory', str(e))
@api.model
def _cron_sync_customers(self):
"""Sync customer address updates to WooCommerce."""
instances = self.search([('state', '=', 'connected'), ('sync_customers', '=', True)])
for instance in instances:
try:
instance._sync_customers()
except Exception as e:
_logger.error("Customer sync failed for %s: %s", instance.name, str(e))
instance._log_sync('customer', 'odoo_to_woo', instance.name, 'failed', str(e))
instance._notify_failure('customer', str(e))
@api.model
def _cron_health_check(self):
"""Ping all connected instances. Only log warnings — never change state.
The health check runs from inside Docker which may not reach external URLs.
State should only be changed by explicit user action (Test Connection button)."""
instances = self.search([('state', '=', 'connected')])
for instance in instances:
try:
client = instance._get_client()
success, _info = client.test_connection()
if success:
_logger.info("Health check OK for %s", instance.name)
else:
_logger.warning("Health check failed for %s — store may be unreachable", instance.name)
except Exception as e:
_logger.warning("Health check error for %s: %s", instance.name, e)
# ------------------------------------------------------------------
# Order Sync (Task 20)
# ------------------------------------------------------------------
def _sync_orders(self):
"""Fetch new WC orders and create Odoo SOs."""
self.ensure_one()
client = self._get_client()
page = 1
while True:
orders = client.get_orders(page=page, status='processing,on-hold,pending')
if not orders:
break
for wc_order in orders:
try:
self._sync_order_from_wc(wc_order)
except Exception as e:
_logger.error(
"Failed to sync WC order %s for %s: %s",
wc_order.get('id'), self.name, e,
)
self._log_sync(
'order', 'woo_to_odoo',
f"WC#{wc_order.get('id')}", 'failed', str(e),
)
page += 1
if len(orders) < 100:
break
self.last_sync = fields.Datetime.now()
def _sync_order_from_wc(self, wc_order):
"""Process a single WC order into an Odoo sale order."""
self.ensure_one()
woo_order_id = wc_order['id']
# Dedup check
existing = self.env['woo.order'].search([
('instance_id', '=', self.id),
('woo_order_id', '=', woo_order_id),
], limit=1)
if existing:
return existing
# Find/create customer
partner = self._find_or_create_customer(wc_order)
# Create sale order
so_vals = self._prepare_sale_order_vals(wc_order, partner)
sale_order = self.env['sale.order'].create(so_vals)
# Add order lines
for line_item in wc_order.get('line_items', []):
line_vals = self._prepare_order_line_vals(sale_order, line_item)
if line_vals:
self.env['sale.order.line'].create(line_vals)
# Add shipping lines
for shipping in wc_order.get('shipping_lines', []):
shipping_vals = self._prepare_shipping_line_vals(sale_order, shipping)
if shipping_vals:
self.env['sale.order.line'].create(shipping_vals)
# Add fee lines
for fee in wc_order.get('fee_lines', []):
fee_vals = {
'order_id': sale_order.id,
'name': fee.get('name', 'Fee'),
'product_uom_qty': 1,
'price_unit': float(fee.get('total', 0)),
}
self.env['sale.order.line'].create(fee_vals)
# Confirm SO
sale_order.action_confirm()
# Create draft invoice
invoice = False
if self.sync_invoices:
try:
invoice = sale_order._create_invoices()
except Exception as e:
_logger.warning("Could not auto-create invoice for %s: %s", sale_order.name, e)
# Create woo.order tracking record
woo_order = self.env['woo.order'].create({
'instance_id': self.id,
'sale_order_id': sale_order.id,
'woo_order_id': woo_order_id,
'woo_order_number': wc_order.get('number', str(woo_order_id)),
'woo_status': wc_order.get('status', ''),
'invoice_id': invoice.id if invoice else False,
'state': 'confirmed',
'company_id': self.company_id.id,
})
# Link invoice to woo.order
if invoice:
invoice.woo_order_id = woo_order.id
# Push SO ref back to WC
try:
client = self._get_client()
client.update_order(woo_order_id, {
'meta_data': [
{'key': '_odoo_order_id', 'value': str(sale_order.id)},
{'key': '_odoo_order_ref', 'value': sale_order.name},
]
})
except Exception as e:
_logger.warning("Failed to push SO ref to WC: %s", e)
self._log_sync(
'order', 'woo_to_odoo', sale_order.name, 'success',
f'Created from WC order #{woo_order.woo_order_number}',
)
return woo_order
def _find_or_create_customer(self, wc_order):
"""Find or create an Odoo partner from a WC order's billing data."""
self.ensure_one()
billing = wc_order.get('billing', {})
email = billing.get('email', '').strip().lower()
wc_customer_id = wc_order.get('customer_id', 0)
if not email:
# Guest checkout — create a one-off partner
name = f"{billing.get('first_name', '')} {billing.get('last_name', '')}".strip()
return self.env['res.partner'].create({
'name': name or 'WooCommerce Guest',
'email': '',
'company_id': self.company_id.id,
})
# Check existing woo.customer link first
if wc_customer_id:
woo_cust = self.env['woo.customer'].search([
('instance_id', '=', self.id),
('woo_customer_id', '=', wc_customer_id),
], limit=1)
if woo_cust:
return woo_cust.partner_id
# Search by email
partner = self.env['res.partner'].search([
('email', '=ilike', email),
'|', ('company_id', '=', self.company_id.id), ('company_id', '=', False),
], limit=1)
if not partner:
partner = self.env['res.partner'].create(
self._prepare_partner_vals(billing)
)
# Ensure woo.customer link exists
if wc_customer_id and not self.env['woo.customer'].search([
('instance_id', '=', self.id),
('woo_customer_id', '=', wc_customer_id),
], limit=1):
self.env['woo.customer'].create({
'instance_id': self.id,
'partner_id': partner.id,
'woo_customer_id': wc_customer_id,
'woo_email': email,
'last_synced': fields.Datetime.now(),
'company_id': self.company_id.id,
})
return partner
def _prepare_partner_vals(self, billing):
"""Build res.partner vals from WC billing data."""
self.ensure_one()
name = f"{billing.get('first_name', '')} {billing.get('last_name', '')}".strip()
country = self.env['res.country'].search([
('code', '=', billing.get('country', '')),
], limit=1)
state = False
if billing.get('state') and country:
state = self.env['res.country.state'].search([
('country_id', '=', country.id),
('code', '=', billing.get('state')),
], limit=1)
return {
'name': name or billing.get('email', 'WooCommerce Customer'),
'email': billing.get('email', ''),
'phone': billing.get('phone', ''),
'street': billing.get('address_1', ''),
'street2': billing.get('address_2', ''),
'city': billing.get('city', ''),
'zip': billing.get('postcode', ''),
'country_id': country.id if country else False,
'state_id': state.id if state else False,
'company_id': self.company_id.id,
}
def _prepare_sale_order_vals(self, wc_order, partner):
"""Build sale.order vals dict from a WC order."""
self.ensure_one()
currency = self.env['res.currency'].search([
('name', '=', wc_order.get('currency', 'CAD')),
], limit=1)
vals = {
'partner_id': partner.id,
'company_id': self.company_id.id,
'client_order_ref': f"WC#{wc_order.get('number', wc_order['id'])}",
}
if currency:
# Set pricelist with matching currency if available
pricelist = self.env['product.pricelist'].search([
('currency_id', '=', currency.id),
'|', ('company_id', '=', self.company_id.id), ('company_id', '=', False),
], limit=1)
if pricelist:
vals['pricelist_id'] = pricelist.id
if self.default_warehouse_id:
vals['warehouse_id'] = self.default_warehouse_id.id
# Shipping address
shipping = wc_order.get('shipping', {})
if shipping.get('first_name') or shipping.get('last_name'):
ship_name = f"{shipping.get('first_name', '')} {shipping.get('last_name', '')}".strip()
ship_country = self.env['res.country'].search([
('code', '=', shipping.get('country', '')),
], limit=1)
ship_state = False
if shipping.get('state') and ship_country:
ship_state = self.env['res.country.state'].search([
('country_id', '=', ship_country.id),
('code', '=', shipping.get('state')),
], limit=1)
ship_partner = self.env['res.partner'].search([
('parent_id', '=', partner.id),
('type', '=', 'delivery'),
('street', '=', shipping.get('address_1', '')),
('city', '=', shipping.get('city', '')),
], limit=1)
if not ship_partner:
ship_partner = self.env['res.partner'].create({
'parent_id': partner.id,
'type': 'delivery',
'name': ship_name,
'street': shipping.get('address_1', ''),
'street2': shipping.get('address_2', ''),
'city': shipping.get('city', ''),
'zip': shipping.get('postcode', ''),
'country_id': ship_country.id if ship_country else False,
'state_id': ship_state.id if ship_state else False,
'company_id': self.company_id.id,
})
vals['partner_shipping_id'] = ship_partner.id
return vals
def _prepare_order_line_vals(self, sale_order, line_item):
"""Build sale.order.line vals from a WC line_item dict."""
self.ensure_one()
wc_product_id = line_item.get('product_id', 0)
wc_variation_id = line_item.get('variation_id', 0)
# Look up mapped product
lookup_id = wc_variation_id or wc_product_id
product_map = self.env['woo.product.map'].search([
('instance_id', '=', self.id),
('woo_product_id', '=', lookup_id),
('state', '=', 'mapped'),
], limit=1)
product = product_map.product_id if product_map else False
# If not mapped, try SKU match
if not product:
sku = line_item.get('sku', '')
if sku:
product = self.env['product.product'].search([
('default_code', '=', sku),
'|', ('company_id', '=', self.company_id.id), ('company_id', '=', False),
], limit=1)
vals = {
'order_id': sale_order.id,
'name': line_item.get('name', 'WooCommerce Product'),
'product_uom_qty': line_item.get('quantity', 1),
'price_unit': float(line_item.get('price', 0)),
}
if product:
vals['product_id'] = product.id
# Tax mapping
taxes = []
for tax_entry in line_item.get('taxes', []):
# WC sends tax class on the line item
pass
wc_tax_class = line_item.get('tax_class', '')
if wc_tax_class:
odoo_tax = self.env['woo.tax.map'].get_odoo_tax(self, wc_tax_class)
if odoo_tax:
taxes.append(odoo_tax.id)
elif line_item.get('total_tax') and float(line_item.get('total_tax', 0)) > 0:
# Standard tax class
odoo_tax = self.env['woo.tax.map'].get_odoo_tax(self, 'standard')
if odoo_tax:
taxes.append(odoo_tax.id)
if taxes:
vals['tax_id'] = [(6, 0, taxes)]
return vals
def _prepare_shipping_line_vals(self, sale_order, shipping_line):
"""Build sale.order.line vals from a WC shipping_lines entry."""
self.ensure_one()
total = float(shipping_line.get('total', 0))
if not total:
return False
return {
'order_id': sale_order.id,
'name': shipping_line.get('method_title', 'Shipping'),
'product_uom_qty': 1,
'price_unit': total,
}
# ------------------------------------------------------------------
# Bulk Price Sync (UI actions)
# ------------------------------------------------------------------
def action_bulk_price_odoo_to_wc(self):
"""Push all Odoo prices to WooCommerce for mapped products."""
self.ensure_one()
maps = self.env['woo.product.map'].search([
('instance_id', '=', self.id),
('state', '=', 'mapped'),
('product_id', '!=', False),
])
maps.action_push_price_to_wc()
def action_bulk_price_wc_to_odoo(self):
"""Pull all WC prices to Odoo for mapped products."""
self.ensure_one()
maps = self.env['woo.product.map'].search([
('instance_id', '=', self.id),
('state', '=', 'mapped'),
('product_id', '!=', False),
])
maps.action_push_price_to_odoo()
# ------------------------------------------------------------------
# Product / Price Sync (Task 22)
# ------------------------------------------------------------------
def _sync_products(self):
"""Sync product prices between Odoo and WooCommerce."""
self.ensure_one()
client = self._get_client()
maps = self.env['woo.product.map'].search([
('instance_id', '=', self.id),
('state', '=', 'mapped'),
('sync_price', '=', True),
('product_id', '!=', False),
])
for pm in maps:
try:
wc_product = client.get_product(pm.woo_product_id)
wc_price = float(wc_product.get('regular_price') or 0)
odoo_price = pm.product_id.list_price
if abs(wc_price - odoo_price) < 0.01:
# Prices match — nothing to do
pm.last_synced = fields.Datetime.now()
continue
# Check if Odoo price was changed since last sync
odoo_changed = True
woo_changed = True
if pm.last_synced:
# If product write_date is newer than last sync, Odoo changed
odoo_changed = pm.product_id.write_date > pm.last_synced
# WC always returns current price, assume changed if different
woo_changed = True
if odoo_changed and woo_changed:
# Both changed — conflict
self.env['woo.conflict'].create({
'instance_id': self.id,
'conflict_type': 'product',
'map_id': pm.id,
'field_name': 'price',
'odoo_value': str(odoo_price),
'woo_value': str(wc_price),
'company_id': self.company_id.id,
})
# Keep state as mapped — conflict is tracked separately
self._log_sync(
'product', 'woo_to_odoo', pm.product_id.display_name,
'conflict', f'Price conflict: Odoo=${odoo_price}, WC=${wc_price}',
)
elif odoo_changed:
# Odoo is authoritative — push to WC
client.update_product(pm.woo_product_id, {
'regular_price': str(odoo_price),
})
self._log_sync(
'product', 'odoo_to_woo', pm.product_id.display_name,
'success', f'Price updated to ${odoo_price}',
)
else:
# WC changed — pull into Odoo
pm.product_id.list_price = wc_price
self._log_sync(
'product', 'woo_to_odoo', pm.product_id.display_name,
'success', f'Price updated to ${wc_price}',
)
pm.last_synced = fields.Datetime.now()
except Exception as e:
_logger.error(
"Product price sync failed for %s (WC#%s): %s",
pm.product_id.display_name, pm.woo_product_id, e,
)
# Don't change map state on sync errors — keep it mapped
self._log_sync(
'product', 'odoo_to_woo',
pm.product_id.display_name, 'failed', str(e),
)
def _sync_product_from_wc(self, wc_data):
"""Handle an inbound WC product webhook — update price if mapped."""
self.ensure_one()
wc_product_id = wc_data.get('id')
if not wc_product_id:
return
pm = self.env['woo.product.map'].search([
('instance_id', '=', self.id),
('woo_product_id', '=', wc_product_id),
('state', '=', 'mapped'),
], limit=1)
if not pm or not pm.product_id:
return
if pm.sync_price:
wc_price = float(wc_data.get('regular_price') or 0)
if wc_price and abs(wc_price - pm.product_id.list_price) > 0.01:
pm.product_id.list_price = wc_price
self._log_sync(
'product', 'woo_to_odoo', pm.product_id.display_name,
'success', f'Price updated via webhook to ${wc_price}',
)
pm.last_synced = fields.Datetime.now()
# ------------------------------------------------------------------
# Inventory Sync (Task 22)
# ------------------------------------------------------------------
def _sync_inventory(self):
"""Push Odoo stock levels to WooCommerce."""
self.ensure_one()
client = self._get_client()
maps = self.env['woo.product.map'].search([
('instance_id', '=', self.id),
('state', '=', 'mapped'),
('sync_inventory', '=', True),
('product_id', '!=', False),
])
for pm in maps:
try:
product = pm.product_id
if self.default_warehouse_id:
# Get qty for specific warehouse
quant = self.env['stock.quant'].search([
('product_id', '=', product.id),
('location_id', 'child_of',
self.default_warehouse_id.lot_stock_id.id),
])
qty = sum(quant.mapped('quantity')) - sum(quant.mapped('reserved_quantity'))
else:
qty = product.qty_available
qty = max(int(qty), 0)
client.update_product(pm.woo_product_id, {
'stock_quantity': qty,
'manage_stock': True,
})
pm.last_synced = fields.Datetime.now()
self._log_sync(
'inventory', 'odoo_to_woo', product.display_name,
'success', f'Stock set to {qty}',
)
except Exception as e:
_logger.error(
"Inventory sync failed for %s (WC#%s): %s",
pm.product_id.display_name, pm.woo_product_id, e,
)
self._log_sync(
'inventory', 'odoo_to_woo',
pm.product_id.display_name, 'failed', str(e),
)
# ------------------------------------------------------------------
# Customer Sync (Task 25)
# ------------------------------------------------------------------
def _sync_customers(self):
"""Push updated Odoo partner addresses to WooCommerce."""
self.ensure_one()
client = self._get_client()
customers = self.env['woo.customer'].search([
('instance_id', '=', self.id),
('woo_customer_id', '>', 0),
])
for cust in customers:
try:
partner = cust.partner_id
# Only sync if partner was modified since last sync
if cust.last_synced and partner.write_date <= cust.last_synced:
continue
billing_data = {
'first_name': (partner.name or '').split(' ', 1)[0],
'last_name': (partner.name or '').split(' ', 1)[1] if ' ' in (partner.name or '') else '',
'email': partner.email or '',
'phone': partner.phone or '',
'address_1': partner.street or '',
'address_2': partner.street2 or '',
'city': partner.city or '',
'postcode': partner.zip or '',
'country': partner.country_id.code if partner.country_id else '',
'state': partner.state_id.code if partner.state_id else '',
}
client.update_customer(cust.woo_customer_id, {
'billing': billing_data,
'shipping': billing_data,
})
cust.last_synced = fields.Datetime.now()
self._log_sync(
'customer', 'odoo_to_woo', partner.display_name,
'success', 'Address updated in WooCommerce',
)
except Exception as e:
_logger.error(
"Customer sync failed for %s (WC#%s): %s",
cust.partner_id.display_name, cust.woo_customer_id, e,
)
self._log_sync(
'customer', 'odoo_to_woo',
cust.partner_id.display_name, 'failed', str(e),
)
def _sync_customer_from_wc(self, wc_data):
"""Handle an inbound WC customer webhook — update partner if linked."""
self.ensure_one()
wc_customer_id = wc_data.get('id')
email = wc_data.get('email', '').strip().lower()
if not wc_customer_id:
return
cust = self.env['woo.customer'].search([
('instance_id', '=', self.id),
('woo_customer_id', '=', wc_customer_id),
], limit=1)
billing = wc_data.get('billing', {})
if cust:
partner = cust.partner_id
# Update partner address from WC
vals = self._prepare_partner_vals(billing)
vals.pop('company_id', None)
partner.write(vals)
cust.last_synced = fields.Datetime.now()
self._log_sync(
'customer', 'woo_to_odoo', partner.display_name,
'success', 'Updated from WC webhook',
)
elif email:
# New customer from WC
self.env['woo.customer']._find_or_create(self, email, wc_data)
# ------------------------------------------------------------------
# Notification
# ------------------------------------------------------------------
def _notify_failure(self, sync_type, error_message):
"""Send email notification on sync failure."""
self.ensure_one()
if not self.notify_on_failure or not self.notify_user_ids:
return
template = self.env.ref(
'fusion_woocommerce.woo_sync_failure_notification', raise_if_not_found=False,
)
if template:
for user in self.notify_user_ids:
template.with_context(
sync_type=sync_type,
error_message=error_message,
).send_mail(self.id, force_send=True, email_values={'email_to': user.email})