Files
Odoo-Modules/fusion-woo-odoo/fusion_woocommerce/models/woo_instance.py
gsinghpal 05c84d077d feat: move tax and pricelist mapping inline to Sync Settings tab
Tax mapping and pricelist mapping now live directly on the instance
form under Sync Settings. Added Fetch WC Tax Classes button that pulls
tax classes from WC API and auto-matches. Removed standalone menu items.

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

1212 lines
50 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')
# Mappings
category_map_ids = fields.One2many('woo.category.map', 'instance_id', string='Category Mappings')
tax_map_ids = fields.One2many('woo.tax.map', 'instance_id', string='Tax Mappings')
pricelist_map_ids = fields.One2many('woo.pricelist.map', 'instance_id', string='Pricelist Mappings')
excluded_category_ids = fields.Many2many(
'product.category', string='Hidden Categories',
help='Products in these categories will be hidden from the unmatched products list.'
)
# AI Configuration
ai_provider = fields.Selection([
('claude', 'Claude (Anthropic)'),
('openai', 'OpenAI'),
], string='AI Provider')
ai_api_key = fields.Char(string='AI API Key', groups='base.group_system')
ai_model = fields.Selection([
# Claude models
('claude-opus-4-20250514', 'Anthropic — Claude Opus 4'),
('claude-sonnet-4-5-20250514', 'Anthropic — Claude Sonnet 4.5'),
('claude-haiku-4-5-20251001', 'Anthropic — Claude Haiku 4.5'),
# OpenAI models
('gpt-4o', 'OpenAI — GPT-4o'),
('gpt-4o-mini', 'OpenAI — GPT-4o Mini'),
('gpt-4-turbo', 'OpenAI — GPT-4 Turbo'),
('gpt-4.1', 'OpenAI — GPT-4.1'),
('gpt-4.1-mini', 'OpenAI — GPT-4.1 Mini'),
('gpt-4.1-nano', 'OpenAI — GPT-4.1 Nano'),
('o3-mini', 'OpenAI — o3 Mini'),
], string='AI Model')
# AI Prompts
prompt_product_title = fields.Text(string='Product Title Prompt',
default='Generate an SEO-optimized product title in Title Case. Keep it concise, include the brand name and key product features. Do not use ALL CAPS.')
prompt_short_description = fields.Text(string='Short Description Prompt',
default='Write a compelling 2-3 sentence product summary in HTML format. Highlight key benefits and features. Use <p> tags.')
prompt_long_description = fields.Text(string='Long Description Prompt',
default='Write a detailed SEO-optimized product description in HTML format. Include sections with <h3> headings for Features, Specifications, and Benefits. Use <ul> lists for features. Make it informative and persuasive.')
prompt_meta_title = fields.Text(string='Meta Title Prompt',
default='Generate an SEO meta title under 60 characters. Include the primary keyword and brand name.')
prompt_meta_description = fields.Text(string='Meta Description Prompt',
default='Generate an SEO meta description under 160 characters. Include a call to action and primary keyword.')
prompt_image_alt = fields.Text(string='Image Alt Text Prompt',
default='Generate descriptive alt text for this product image. Be specific about the product shown. Keep under 125 characters.')
prompt_image_caption = fields.Text(string='Image Caption Prompt',
default='Generate a short image caption for this product photo. Include the product name and key visible feature.')
prompt_keywords = fields.Text(string='Keywords Prompt',
default='Generate 5-8 SEO focus keywords for this product, comma-separated. Include long-tail keywords.')
# Company Info for Image Geo-tagging
geo_company_name = fields.Char(string='Company Name (Geo-tag)', compute='_compute_geo_info', store=False)
geo_company_address = fields.Char(string='Company Address (Geo-tag)', compute='_compute_geo_info', store=False)
geo_company_phone = fields.Char(string='Company Phone (Geo-tag)', compute='_compute_geo_info', store=False)
geo_lat = fields.Float(string='GPS Latitude', digits=(10, 7))
geo_lng = fields.Float(string='GPS Longitude', digits=(10, 7))
# 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),
])
@api.depends('company_id')
def _compute_geo_info(self):
for rec in self:
company = rec.company_id
rec.geo_company_name = company.name or ''
rec.geo_company_address = ', '.join(filter(None, [
company.street,
company.city,
company.state_id.name if company.state_id else '',
company.zip,
company.country_id.name if company.country_id else '',
]))
rec.geo_company_phone = company.phone or ''
def action_fetch_wc_categories(self):
"""Fetch all WooCommerce categories and display for mapping."""
self.ensure_one()
client = self._get_client()
CategoryMap = self.env['woo.category.map']
page = 1
fetched = 0
while True:
try:
cats = client.get('products/categories', params={'page': page, 'per_page': 100})
except Exception as e:
raise UserError('Failed to fetch WC categories: %s' % str(e))
if not cats:
break
for wc_cat in cats:
wc_id = wc_cat['id']
existing = CategoryMap.search([
('instance_id', '=', self.id),
('woo_category_id', '=', wc_id),
], limit=1)
if existing:
existing.write({
'woo_category_name': wc_cat.get('name', ''),
'woo_category_slug': wc_cat.get('slug', ''),
})
else:
# Try auto-match by name
odoo_cat = self.env['product.category'].search([
('name', '=ilike', wc_cat.get('name', '')),
], limit=1)
CategoryMap.create({
'instance_id': self.id,
'odoo_category_id': odoo_cat.id if odoo_cat else False,
'woo_category_id': wc_id,
'woo_category_name': wc_cat.get('name', ''),
'woo_category_slug': wc_cat.get('slug', ''),
'company_id': self.company_id.id,
})
fetched += 1
page += 1
if len(cats) < 100:
break
return True
def action_fetch_wc_tax_classes(self):
"""Fetch WooCommerce tax classes for mapping."""
self.ensure_one()
client = self._get_client()
TaxMap = self.env['woo.tax.map']
try:
tax_classes = client.get_tax_classes()
except Exception as e:
raise UserError('Failed to fetch WC tax classes: %s' % str(e))
for tc in tax_classes:
slug = tc.get('slug', '')
name = tc.get('name', '')
existing = TaxMap.search([
('instance_id', '=', self.id),
('woo_tax_class', '=', slug),
], limit=1)
if existing:
existing.woo_tax_class_name = name
else:
# Try to auto-match by name similarity
odoo_tax = False
if 'zero' in slug.lower() or 'exempt' in slug.lower():
odoo_tax = self.env['account.tax'].search([
('type_tax_use', '=', 'sale'),
('amount', '=', 0),
('company_id', '=', self.company_id.id),
], limit=1)
elif 'standard' in slug.lower():
odoo_tax = self.env['account.tax'].search([
('type_tax_use', '=', 'sale'),
('amount', '>', 0),
('company_id', '=', self.company_id.id),
], limit=1, order='amount desc')
TaxMap.create({
'instance_id': self.id,
'tax_id': odoo_tax.id if odoo_tax else False,
'woo_tax_class': slug,
'woo_tax_class_name': name,
'company_id': self.company_id.id,
})
return True
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_regular_price = 0.0
wc_sale_price = 0.0
try:
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
wc_permalink = wc_prod.get('permalink', '')
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_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,
'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_regular = 0.0
var_sale = 0.0
try:
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
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_regular_price': var_regular,
'woo_sale_price': var_sale,
'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 standard + sale prices for all 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)
regular = 0.0
sale = 0.0
try:
regular = float(wc_prod.get('regular_price') or 0)
except (ValueError, TypeError):
pass
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)
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()
# ------------------------------------------------------------------
# Bulk SKU Sync (UI actions)
# ------------------------------------------------------------------
def action_bulk_sku_odoo_to_wc(self):
"""Push all Odoo SKUs to WooCommerce."""
self.ensure_one()
maps = self.env['woo.product.map'].search([
('instance_id', '=', self.id),
('state', '=', 'mapped'),
('product_id', '!=', False),
])
maps.action_push_sku_to_wc()
def action_bulk_sku_wc_to_odoo(self):
"""Pull all WC SKUs to Odoo."""
self.ensure_one()
maps = self.env['woo.product.map'].search([
('instance_id', '=', self.id),
('state', '=', 'mapped'),
('product_id', '!=', False),
])
maps.action_push_sku_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})