1335 lines
56 KiB
Python
1335 lines
56 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', '')
|
|
|
|
wc_categories = wc_prod.get('categories', [])
|
|
wc_cat_id = wc_categories[0].get('id', 0) if wc_categories else 0
|
|
wc_cat_name = wc_categories[0].get('name', '') if wc_categories else ''
|
|
|
|
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_category_id': wc_cat_id,
|
|
'woo_category_name': wc_cat_name,
|
|
'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
|
|
|
|
# Max consecutive API errors before aborting a sync loop
|
|
_MAX_CONSECUTIVE_ERRORS = 5
|
|
|
|
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
|
|
consecutive_errors = 0
|
|
for m in maps:
|
|
try:
|
|
wc_prod = client.get_product(m.woo_product_id)
|
|
consecutive_errors = 0
|
|
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:
|
|
consecutive_errors += 1
|
|
_logger.warning("Failed to refresh price for WC#%s: %s", m.woo_product_id, e)
|
|
if consecutive_errors >= self._MAX_CONSECUTIVE_ERRORS:
|
|
_logger.error(
|
|
"Aborting price refresh after %d consecutive errors", consecutive_errors)
|
|
self._log_sync('product', 'woo_to_odoo', self.name, 'failed',
|
|
'Aborted after %d consecutive API errors' % consecutive_errors)
|
|
return True
|
|
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
|
|
consecutive_fetch_errors = 0
|
|
while True:
|
|
try:
|
|
orders = client.get_orders(page=page, status='processing,on-hold,pending')
|
|
consecutive_fetch_errors = 0
|
|
except Exception as e:
|
|
consecutive_fetch_errors += 1
|
|
_logger.error("Failed to fetch orders page %d: %s", page, e)
|
|
if consecutive_fetch_errors >= 3:
|
|
self._log_sync('order', 'woo_to_odoo', self.name, 'failed',
|
|
'Aborted order fetch after %d consecutive page errors' % consecutive_fetch_errors)
|
|
return
|
|
page += 1
|
|
continue
|
|
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_product = self._get_service_product('WC Fee', 'WC-FEE')
|
|
fee_vals = {
|
|
'order_id': sale_order.id,
|
|
'name': fee.get('name', 'Fee'),
|
|
'product_id': fee_product.id,
|
|
'product_uom_qty': 1,
|
|
'price_unit': float(fee.get('total', 0)),
|
|
}
|
|
self.env['sale.order.line'].create(fee_vals)
|
|
|
|
# Create woo.order tracking record FIRST to prevent infinite retries
|
|
# if action_confirm or invoicing fails later.
|
|
wc_status = wc_order.get('status', '')
|
|
odoo_state = self.env['woo.order'].WC_STATUS_TO_STATE.get(wc_status, 'confirmed')
|
|
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_status if wc_status in dict(self.env['woo.order']._fields['woo_status'].selection) else False,
|
|
'state': odoo_state,
|
|
'company_id': self.company_id.id,
|
|
})
|
|
|
|
# Confirm SO (non-fatal — keeps SO in draft if it fails)
|
|
try:
|
|
sale_order.action_confirm()
|
|
except Exception as e:
|
|
_logger.warning(
|
|
"Could not auto-confirm SO %s (WC#%s): %s — order left in draft",
|
|
sale_order.name, woo_order_id, e,
|
|
)
|
|
self._log_sync(
|
|
'order', 'woo_to_odoo', sale_order.name, 'failed',
|
|
f'Order imported but could not auto-confirm: {e}',
|
|
)
|
|
return woo_order
|
|
|
|
# Create draft invoice
|
|
invoice = False
|
|
if self.sync_invoices:
|
|
try:
|
|
invoice = sale_order._create_invoices()
|
|
woo_order.invoice_id = invoice.id
|
|
except Exception as e:
|
|
_logger.warning("Could not auto-create invoice for %s: %s", sale_order.name, e)
|
|
|
|
# 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)
|
|
|
|
if not product:
|
|
wc_name = line_item.get('name', 'WooCommerce Product')
|
|
wc_sku = line_item.get('sku', '')
|
|
wc_price = float(line_item.get('price', 0))
|
|
product = self.env['product.product'].create({
|
|
'name': wc_name.upper() if wc_name else 'WC PRODUCT',
|
|
'default_code': wc_sku,
|
|
'list_price': wc_price,
|
|
'type': 'consu',
|
|
})
|
|
if lookup_id:
|
|
self.env['woo.product.map'].create({
|
|
'instance_id': self.id,
|
|
'product_id': product.id,
|
|
'woo_product_id': lookup_id,
|
|
'woo_product_name': wc_name,
|
|
'woo_sku': wc_sku,
|
|
'woo_regular_price': wc_price,
|
|
'woo_product_type': 'simple',
|
|
'state': 'mapped',
|
|
'company_id': self.company_id.id,
|
|
})
|
|
_logger.info(
|
|
"Auto-created Odoo product '%s' (SKU: %s) for WC order line",
|
|
product.name, wc_sku,
|
|
)
|
|
|
|
vals = {
|
|
'order_id': sale_order.id,
|
|
'name': line_item.get('name', 'WooCommerce Product'),
|
|
'product_id': product.id,
|
|
'product_uom_qty': line_item.get('quantity', 1),
|
|
'price_unit': float(line_item.get('price', 0)),
|
|
}
|
|
|
|
# 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 _get_service_product(self, name, internal_ref):
|
|
"""Find or create a generic service product (for shipping, fees, etc.)."""
|
|
product = self.env['product.product'].search([
|
|
('default_code', '=', internal_ref),
|
|
'|', ('company_id', '=', self.company_id.id), ('company_id', '=', False),
|
|
], limit=1)
|
|
if not product:
|
|
product = self.env['product.product'].create({
|
|
'name': name,
|
|
'default_code': internal_ref,
|
|
'type': 'service',
|
|
'list_price': 0,
|
|
'sale_ok': True,
|
|
'purchase_ok': False,
|
|
})
|
|
return product
|
|
|
|
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
|
|
shipping_product = self._get_service_product('WC Shipping', 'WC-SHIPPING')
|
|
return {
|
|
'order_id': sale_order.id,
|
|
'name': shipping_line.get('method_title', 'Shipping'),
|
|
'product_id': shipping_product.id,
|
|
'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),
|
|
])
|
|
|
|
consecutive_errors = 0
|
|
for pm in maps:
|
|
try:
|
|
wc_product = client.get_product(pm.woo_product_id)
|
|
consecutive_errors = 0
|
|
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:
|
|
pm.last_synced = fields.Datetime.now()
|
|
continue
|
|
|
|
odoo_changed = True
|
|
woo_changed = True
|
|
|
|
if pm.last_synced:
|
|
odoo_changed = pm.product_id.write_date > pm.last_synced
|
|
# WooCommerce returns ISO 8601 in date_modified_gmt (UTC).
|
|
wc_modified_str = (
|
|
wc_product.get('date_modified_gmt')
|
|
or wc_product.get('date_modified')
|
|
)
|
|
if wc_modified_str:
|
|
try:
|
|
wc_modified = fields.Datetime.from_string(
|
|
wc_modified_str.replace('T', ' ').split('.')[0]
|
|
)
|
|
woo_changed = wc_modified and wc_modified > pm.last_synced
|
|
except (ValueError, TypeError):
|
|
woo_changed = False
|
|
else:
|
|
woo_changed = False
|
|
|
|
if odoo_changed and woo_changed:
|
|
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,
|
|
})
|
|
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:
|
|
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:
|
|
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:
|
|
consecutive_errors += 1
|
|
_logger.error(
|
|
"Product price sync failed for %s (WC#%s): %s",
|
|
pm.product_id.display_name, pm.woo_product_id, e,
|
|
)
|
|
self._log_sync(
|
|
'product', 'odoo_to_woo',
|
|
pm.product_id.display_name, 'failed', str(e),
|
|
)
|
|
if consecutive_errors >= self._MAX_CONSECUTIVE_ERRORS:
|
|
_logger.error(
|
|
"Aborting product sync after %d consecutive errors",
|
|
consecutive_errors)
|
|
self._log_sync('product', 'woo_to_odoo', self.name, 'failed',
|
|
'Aborted after %d consecutive API errors' % consecutive_errors)
|
|
return
|
|
|
|
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),
|
|
])
|
|
|
|
consecutive_errors = 0
|
|
for pm in maps:
|
|
try:
|
|
product = pm.product_id
|
|
if self.default_warehouse_id:
|
|
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,
|
|
})
|
|
consecutive_errors = 0
|
|
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:
|
|
consecutive_errors += 1
|
|
_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),
|
|
)
|
|
if consecutive_errors >= self._MAX_CONSECUTIVE_ERRORS:
|
|
_logger.error(
|
|
"Aborting inventory sync after %d consecutive errors",
|
|
consecutive_errors)
|
|
self._log_sync('inventory', 'odoo_to_woo', self.name, 'failed',
|
|
'Aborted after %d consecutive API errors' % consecutive_errors)
|
|
return
|
|
|
|
# ------------------------------------------------------------------
|
|
# 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),
|
|
])
|
|
|
|
consecutive_errors = 0
|
|
for cust in customers:
|
|
try:
|
|
partner = cust.partner_id
|
|
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,
|
|
})
|
|
consecutive_errors = 0
|
|
|
|
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:
|
|
consecutive_errors += 1
|
|
_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),
|
|
)
|
|
if consecutive_errors >= self._MAX_CONSECUTIVE_ERRORS:
|
|
_logger.error(
|
|
"Aborting customer sync after %d consecutive errors",
|
|
consecutive_errors)
|
|
self._log_sync('customer', 'odoo_to_woo', self.name, 'failed',
|
|
'Aborted after %d consecutive API errors' % consecutive_errors)
|
|
return
|
|
|
|
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})
|