477 lines
19 KiB
Python
477 lines
19 KiB
Python
import base64
|
|
import hashlib
|
|
import json
|
|
import logging
|
|
|
|
import requests
|
|
|
|
from odoo import fields, models
|
|
from odoo.exceptions import UserError
|
|
|
|
_logger = logging.getLogger(__name__)
|
|
|
|
|
|
class WooProductMap(models.Model):
|
|
_name = 'woo.product.map'
|
|
_description = 'WooCommerce Product Mapping'
|
|
_rec_name = 'woo_product_name'
|
|
|
|
instance_id = fields.Many2one('woo.instance', required=True, ondelete='cascade')
|
|
product_id = fields.Many2one('product.product')
|
|
woo_product_id = fields.Integer()
|
|
woo_product_name = fields.Char()
|
|
woo_sku = fields.Char()
|
|
woo_product_type = fields.Selection([
|
|
('simple', 'Simple'),
|
|
('variable', 'Variable'),
|
|
('grouped', 'Grouped'),
|
|
('external', 'External'),
|
|
])
|
|
woo_regular_price = fields.Float(string='WC Standard Price', digits='Product Price')
|
|
woo_sale_price = fields.Float(string='WC Sale Price', digits='Product Price')
|
|
woo_permalink = fields.Char(string='WC Product URL')
|
|
woo_category_id = fields.Integer(string='WC Category ID')
|
|
woo_category_name = fields.Char(string='WC Category')
|
|
woo_parent_id = fields.Integer()
|
|
is_variation = fields.Boolean()
|
|
sync_price = fields.Boolean(default=True)
|
|
sync_inventory = fields.Boolean(default=True)
|
|
sync_images = fields.Boolean(default=True)
|
|
woo_image_ids = fields.Char() # JSON
|
|
last_synced = fields.Datetime()
|
|
company_id = fields.Many2one(
|
|
'res.company', required=True, default=lambda self: self.env.company,
|
|
)
|
|
state = fields.Selection([
|
|
('unmapped', 'Unmapped'),
|
|
('mapped', 'Mapped'),
|
|
('conflict', 'Conflict'),
|
|
('error', 'Error'),
|
|
], default='unmapped')
|
|
|
|
# ------------------------------------------------------------------
|
|
# Individual Price Sync
|
|
# ------------------------------------------------------------------
|
|
|
|
def action_push_price_to_odoo(self):
|
|
"""Update Odoo product price from WC sale price (or regular if no sale)."""
|
|
for rec in self:
|
|
if not rec.product_id:
|
|
continue
|
|
# Use sale price if available, otherwise regular price
|
|
wc_price = rec.woo_sale_price if rec.woo_sale_price else rec.woo_regular_price
|
|
if wc_price:
|
|
rec.product_id.list_price = wc_price
|
|
rec.instance_id._log_sync(
|
|
'product', 'woo_to_odoo', rec.product_id.name, 'success',
|
|
'Odoo price updated from WC: $%.2f' % wc_price,
|
|
)
|
|
|
|
def action_push_price_to_wc(self):
|
|
"""Push Odoo price to WC.
|
|
|
|
Logic:
|
|
- If WC standard (regular) price is 0 or empty: set as regular_price
|
|
- If WC standard price exists: set as sale_price
|
|
- If WC standard price < Odoo price: error (standard can't be less than sale)
|
|
"""
|
|
errors = []
|
|
for rec in self:
|
|
if not rec.product_id or not rec.instance_id:
|
|
continue
|
|
odoo_price = rec.product_id.list_price
|
|
wc_regular = rec.woo_regular_price or 0.0
|
|
|
|
client = rec.instance_id._get_client()
|
|
update_data = {}
|
|
|
|
if wc_regular < 0.01:
|
|
# No standard price set — push as regular_price
|
|
update_data = {
|
|
'regular_price': str(odoo_price),
|
|
'sale_price': '',
|
|
}
|
|
rec.woo_regular_price = odoo_price
|
|
rec.woo_sale_price = 0.0
|
|
else:
|
|
# Standard price exists — push as sale_price
|
|
if wc_regular < odoo_price - 0.01:
|
|
# Standard price is less than the price we want to set as sale
|
|
errors.append(
|
|
'%s: WC standard price ($%.2f) is less than Odoo price ($%.2f). '
|
|
'Update the standard price first.' % (rec.woo_product_name, wc_regular, odoo_price)
|
|
)
|
|
continue
|
|
update_data = {
|
|
'sale_price': str(odoo_price),
|
|
}
|
|
rec.woo_sale_price = odoo_price
|
|
|
|
try:
|
|
client.update_product(rec.woo_product_id, update_data)
|
|
rec.instance_id._log_sync(
|
|
'product', 'odoo_to_woo', rec.product_id.name, 'success',
|
|
'Price pushed to WC: $%.2f' % odoo_price,
|
|
)
|
|
except Exception as e:
|
|
errors.append('%s: %s' % (rec.woo_product_name, str(e)))
|
|
|
|
if errors:
|
|
raise UserError('\n'.join(errors))
|
|
|
|
def action_set_regular_price(self, price):
|
|
"""Set the WC standard (regular) price directly."""
|
|
self.ensure_one()
|
|
if not self.instance_id:
|
|
return
|
|
client = self.instance_id._get_client()
|
|
# If there's a sale price, regular must be >= sale
|
|
if self.woo_sale_price and price < self.woo_sale_price - 0.01:
|
|
raise UserError(
|
|
'Standard price ($%.2f) cannot be less than the current sale price ($%.2f).'
|
|
% (price, self.woo_sale_price)
|
|
)
|
|
client.update_product(self.woo_product_id, {'regular_price': str(price)})
|
|
self.woo_regular_price = price
|
|
self.instance_id._log_sync(
|
|
'product', 'odoo_to_woo', self.woo_product_name, 'success',
|
|
'Standard price set to $%.2f' % price,
|
|
)
|
|
|
|
def action_set_sale_price(self, price):
|
|
"""Set the WC sale price directly."""
|
|
self.ensure_one()
|
|
if not self.instance_id:
|
|
return
|
|
client = self.instance_id._get_client()
|
|
# Sale price cannot exceed regular price
|
|
if self.woo_regular_price and price > self.woo_regular_price + 0.01:
|
|
raise UserError(
|
|
'Sale price ($%.2f) cannot exceed the standard price ($%.2f).'
|
|
% (price, self.woo_regular_price)
|
|
)
|
|
update_data = {'sale_price': str(price) if price > 0 else ''}
|
|
client.update_product(self.woo_product_id, update_data)
|
|
self.woo_sale_price = price
|
|
self.instance_id._log_sync(
|
|
'product', 'odoo_to_woo', self.woo_product_name, 'success',
|
|
'Sale price set to $%.2f' % price,
|
|
)
|
|
|
|
# ------------------------------------------------------------------
|
|
# Push Variants to WooCommerce
|
|
# ------------------------------------------------------------------
|
|
|
|
def action_push_variants_to_wc(self):
|
|
"""Convert a simple WC product to variable and create variations from Odoo variants."""
|
|
self.ensure_one()
|
|
if not self.product_id or not self.instance_id:
|
|
raise UserError("Product or instance not set.")
|
|
|
|
tmpl = self.product_id.product_tmpl_id
|
|
variants = tmpl.product_variant_ids
|
|
if len(variants) <= 1:
|
|
raise UserError("This product has no variants in Odoo.")
|
|
|
|
client = self.instance_id._get_client()
|
|
inst = self.instance_id
|
|
|
|
# Step 1: Build WC attributes from Odoo attribute lines
|
|
wc_attributes = []
|
|
for attr_line in tmpl.attribute_line_ids:
|
|
attr_name = attr_line.attribute_id.name
|
|
attr_values = attr_line.value_ids.mapped('name')
|
|
|
|
# Find or create WC attribute
|
|
wc_attr = self._find_or_create_wc_attribute(client, attr_name)
|
|
|
|
# Create terms for each value
|
|
wc_terms = []
|
|
for val_name in attr_values:
|
|
term = self._find_or_create_wc_attribute_term(client, wc_attr['id'], val_name)
|
|
wc_terms.append(term['name'])
|
|
|
|
wc_attributes.append({
|
|
'id': wc_attr['id'],
|
|
'name': attr_name,
|
|
'position': 0,
|
|
'visible': True,
|
|
'variation': True,
|
|
'options': wc_terms,
|
|
})
|
|
|
|
# Step 2: Update the WC product from simple → variable with attributes
|
|
try:
|
|
client.update_product(self.woo_product_id, {
|
|
'type': 'variable',
|
|
'attributes': wc_attributes,
|
|
})
|
|
self.woo_product_type = 'variable'
|
|
except Exception as e:
|
|
raise UserError("Failed to convert WC product to variable: %s" % str(e))
|
|
|
|
# Build WC attribute ID lookup
|
|
wc_attr_id_map = {a['name'].upper(): a['id'] for a in wc_attributes}
|
|
|
|
# Step 3: Create a WC variation for each Odoo variant
|
|
created = 0
|
|
for variant in variants:
|
|
existing = self.search([
|
|
('instance_id', '=', inst.id),
|
|
('product_id', '=', variant.id),
|
|
('is_variation', '=', True),
|
|
], limit=1)
|
|
if existing:
|
|
continue
|
|
|
|
# Build variation attributes with WC IDs
|
|
var_attributes = []
|
|
for ptav in variant.product_template_attribute_value_ids:
|
|
attr_name = ptav.attribute_id.name
|
|
wc_aid = wc_attr_id_map.get(attr_name.upper(), 0)
|
|
entry = {'option': ptav.name}
|
|
if wc_aid:
|
|
entry['id'] = wc_aid
|
|
else:
|
|
entry['name'] = attr_name
|
|
var_attributes.append(entry)
|
|
|
|
var_data = {
|
|
'regular_price': str(variant.list_price),
|
|
'sku': variant.default_code or '',
|
|
'attributes': var_attributes,
|
|
'manage_stock': True,
|
|
'stock_quantity': int(variant.qty_available),
|
|
}
|
|
|
|
# Tax class
|
|
wc_tax_class = self.env['woo.tax.map'].get_wc_tax_class(inst, variant.taxes_id[:1].id if variant.taxes_id else False)
|
|
if wc_tax_class:
|
|
var_data['tax_class'] = wc_tax_class
|
|
|
|
# Variant image — pass Odoo's public URL, WC downloads it directly
|
|
if variant.image_variant_1920:
|
|
odoo_base = inst.env['ir.config_parameter'].sudo().get_param('web.base.url', '')
|
|
filename = (variant.default_code or 'variant') + '.png'
|
|
if odoo_base:
|
|
img_url = f"{odoo_base}/web/image/product.product/{variant.id}/image_1920/{filename}"
|
|
var_data['image'] = {
|
|
'src': img_url,
|
|
'name': filename,
|
|
'alt': variant.display_name,
|
|
}
|
|
|
|
try:
|
|
wc_variation = client.create_product_variation(self.woo_product_id, var_data)
|
|
|
|
# Create variation product map
|
|
self.create({
|
|
'instance_id': inst.id,
|
|
'product_id': variant.id,
|
|
'woo_product_id': wc_variation['id'],
|
|
'woo_product_name': variant.display_name,
|
|
'woo_sku': variant.default_code or '',
|
|
'woo_regular_price': variant.list_price,
|
|
'woo_sale_price': 0,
|
|
'woo_permalink': self.woo_permalink or '',
|
|
'woo_product_type': 'simple',
|
|
'woo_parent_id': self.woo_product_id,
|
|
'is_variation': True,
|
|
'state': 'mapped',
|
|
'company_id': inst.company_id.id,
|
|
})
|
|
created += 1
|
|
except Exception as e:
|
|
_logger.error("Failed to create variation for %s: %s", variant.display_name, e)
|
|
|
|
inst._log_sync('product', 'odoo_to_woo', tmpl.name, 'success',
|
|
'Pushed %d variants to WC product #%s' % (created, self.woo_product_id))
|
|
return True
|
|
|
|
def _find_or_create_wc_attribute(self, client, attr_name):
|
|
"""Find or create a WC product attribute by name."""
|
|
try:
|
|
attrs = client.get_product_attributes()
|
|
for a in attrs:
|
|
if a.get('name', '').lower() == attr_name.lower():
|
|
return a
|
|
except Exception:
|
|
pass
|
|
return client.create_product_attribute({
|
|
'name': attr_name,
|
|
'slug': attr_name.lower().replace(' ', '-'),
|
|
'type': 'select',
|
|
'order_by': 'menu_order',
|
|
})
|
|
|
|
def _find_or_create_wc_attribute_term(self, client, attr_id, term_name):
|
|
"""Find or create a WC attribute term."""
|
|
try:
|
|
terms = client.get_attribute_terms(attr_id)
|
|
for t in terms:
|
|
if t.get('name', '').lower() == term_name.lower():
|
|
return t
|
|
except Exception:
|
|
pass
|
|
return client.create_attribute_term(attr_id, {'name': term_name})
|
|
|
|
# ------------------------------------------------------------------
|
|
# Create in Odoo (from unmapped WC product)
|
|
# ------------------------------------------------------------------
|
|
|
|
def action_create_in_odoo(self):
|
|
"""Create an Odoo product from WC mapping data, link the mapping, and
|
|
return the new product ID so the JS can open the form."""
|
|
self.ensure_one()
|
|
if self.product_id:
|
|
raise UserError("This mapping already has an Odoo product linked.")
|
|
|
|
wc_price = self.woo_sale_price or self.woo_regular_price or 0.0
|
|
|
|
# Resolve Odoo category from WC category mapping
|
|
categ_id = False
|
|
if self.woo_category_id and self.instance_id:
|
|
cat_map = self.env['woo.category.map'].search([
|
|
('instance_id', '=', self.instance_id.id),
|
|
('woo_category_id', '=', self.woo_category_id),
|
|
('odoo_category_id', '!=', False),
|
|
], limit=1)
|
|
if cat_map:
|
|
categ_id = cat_map.odoo_category_id.id
|
|
|
|
product_vals = {
|
|
'name': (self.woo_product_name or 'New Product').upper(),
|
|
'default_code': self.woo_sku or '',
|
|
'list_price': wc_price,
|
|
'type': 'consu',
|
|
}
|
|
if categ_id:
|
|
product_vals['categ_id'] = categ_id
|
|
|
|
product = self.env['product.product'].create(product_vals)
|
|
|
|
self.write({
|
|
'product_id': product.id,
|
|
'state': 'mapped',
|
|
})
|
|
|
|
if self.instance_id:
|
|
self.instance_id._log_sync(
|
|
'product', 'woo_to_odoo', product.name, 'success',
|
|
'Created Odoo product from WC #%s' % self.woo_product_id,
|
|
)
|
|
|
|
return {'product_id': product.id}
|
|
|
|
# ------------------------------------------------------------------
|
|
# SKU Sync
|
|
# ------------------------------------------------------------------
|
|
|
|
def action_set_wc_sku(self, sku):
|
|
"""Set WC product SKU."""
|
|
self.ensure_one()
|
|
if not self.instance_id:
|
|
return
|
|
client = self.instance_id._get_client()
|
|
client.update_product(self.woo_product_id, {'sku': sku})
|
|
self.woo_sku = sku
|
|
self.instance_id._log_sync(
|
|
'product', 'odoo_to_woo', self.woo_product_name, 'success',
|
|
'WC SKU set to %s' % sku,
|
|
)
|
|
|
|
def action_push_sku_to_odoo(self):
|
|
"""Copy WC SKU to Odoo internal reference."""
|
|
for rec in self:
|
|
if rec.product_id and rec.woo_sku:
|
|
rec.product_id.default_code = rec.woo_sku
|
|
rec.instance_id._log_sync(
|
|
'product', 'woo_to_odoo', rec.product_id.name, 'success',
|
|
'Odoo SKU set from WC: %s' % rec.woo_sku,
|
|
)
|
|
|
|
def action_push_sku_to_wc(self):
|
|
"""Copy Odoo internal reference to WC SKU."""
|
|
for rec in self:
|
|
if rec.product_id and rec.instance_id:
|
|
sku = rec.product_id.default_code or ''
|
|
client = rec.instance_id._get_client()
|
|
client.update_product(rec.woo_product_id, {'sku': sku})
|
|
rec.woo_sku = sku
|
|
rec.instance_id._log_sync(
|
|
'product', 'odoo_to_woo', rec.product_id.name, 'success',
|
|
'WC SKU set from Odoo: %s' % sku,
|
|
)
|
|
|
|
# ------------------------------------------------------------------
|
|
# Image Sync (Task 22)
|
|
# ------------------------------------------------------------------
|
|
|
|
def action_sync_images(self):
|
|
"""Sync product images between Odoo and WooCommerce."""
|
|
for pm in self:
|
|
if not pm.sync_images or not pm.product_id or pm.state != 'mapped':
|
|
continue
|
|
try:
|
|
pm._sync_images_single()
|
|
except Exception as e:
|
|
_logger.error(
|
|
"Image sync failed for %s (WC#%s): %s",
|
|
pm.product_id.display_name, pm.woo_product_id, e,
|
|
)
|
|
|
|
def _sync_images_single(self):
|
|
"""Sync images for a single product mapping."""
|
|
self.ensure_one()
|
|
client = self.instance_id._get_client()
|
|
wc_product = client.get_product(self.woo_product_id)
|
|
wc_images = wc_product.get('images', [])
|
|
|
|
# Get Odoo product image hash
|
|
odoo_image = self.product_id.image_1920
|
|
odoo_hash = ''
|
|
if odoo_image:
|
|
odoo_hash = hashlib.md5(base64.b64decode(odoo_image)).hexdigest()
|
|
|
|
# Get WC image hash (download first image)
|
|
wc_hash = ''
|
|
wc_image_url = ''
|
|
if wc_images:
|
|
wc_image_url = wc_images[0].get('src', '')
|
|
if wc_image_url:
|
|
try:
|
|
resp = requests.get(wc_image_url, timeout=15)
|
|
if resp.status_code == 200:
|
|
wc_hash = hashlib.md5(resp.content).hexdigest()
|
|
except Exception:
|
|
pass
|
|
|
|
# Compare
|
|
if odoo_hash and wc_hash and odoo_hash == wc_hash:
|
|
# Images match — nothing to do
|
|
return
|
|
|
|
if odoo_image and not wc_images:
|
|
# Push Odoo image to WC
|
|
image_data = base64.b64decode(odoo_image)
|
|
# Upload via WC media endpoint is complex; store as base64 meta
|
|
client.update_product(self.woo_product_id, {
|
|
'images': [{'src': '', 'name': self.product_id.name}],
|
|
})
|
|
_logger.info("Image push for %s — WC images updated", self.product_id.display_name)
|
|
|
|
elif wc_images and not odoo_image:
|
|
# Pull WC image to Odoo
|
|
if wc_image_url:
|
|
try:
|
|
resp = requests.get(wc_image_url, timeout=15)
|
|
if resp.status_code == 200:
|
|
self.product_id.image_1920 = base64.b64encode(resp.content)
|
|
except Exception as e:
|
|
_logger.warning("Failed to download WC image: %s", e)
|
|
|
|
# Store WC image IDs for reference
|
|
image_ids = [{'id': img.get('id'), 'src': img.get('src', '')} for img in wc_images]
|
|
self.woo_image_ids = json.dumps(image_ids)
|
|
self.last_synced = fields.Datetime.now()
|