diff --git a/fusion-woo-odoo/check_products.sh b/fusion-woo-odoo/check_products.sh new file mode 100644 index 00000000..a7c66b10 --- /dev/null +++ b/fusion-woo-odoo/check_products.sh @@ -0,0 +1,11 @@ +#!/bin/bash +echo "=== Recently created products ===" +PGPASSWORD='DevSecure2025!' psql -h db -U odoo -d westin-v19 -c "SELECT id, name, default_code, list_price, create_date FROM product_product ORDER BY create_date DESC LIMIT 5;" + +echo "" +echo "=== Recent woo.product.map changes ===" +PGPASSWORD='DevSecure2025!' psql -h db -U odoo -d westin-v19 -c "SELECT id, woo_product_name, woo_sku, product_id, state, write_date FROM woo_product_map ORDER BY write_date DESC LIMIT 5;" + +echo "" +echo "=== Unmapped WC products ===" +PGPASSWORD='DevSecure2025!' psql -h db -U odoo -d westin-v19 -c "SELECT id, woo_product_name, woo_sku, product_id, state FROM woo_product_map WHERE state='unmapped' LIMIT 5;" diff --git a/fusion-woo-odoo/fusion-woodoo.zip b/fusion-woo-odoo/fusion-woodoo.zip deleted file mode 100644 index fc9aa8ec..00000000 Binary files a/fusion-woo-odoo/fusion-woodoo.zip and /dev/null differ diff --git a/fusion-woo-odoo/fusion_woocommerce/__manifest__.py b/fusion-woo-odoo/fusion_woocommerce/__manifest__.py index 797492a9..b261d06d 100644 --- a/fusion-woo-odoo/fusion_woocommerce/__manifest__.py +++ b/fusion-woo-odoo/fusion_woocommerce/__manifest__.py @@ -38,7 +38,6 @@ ], 'assets': { 'web.assets_backend': [ - 'fusion_woocommerce/static/src/js/theme_detect.js', 'fusion_woocommerce/static/src/css/woo_styles.css', 'fusion_woocommerce/static/src/js/ajax_search.js', 'fusion_woocommerce/static/src/js/product_mapping.js', diff --git a/fusion-woo-odoo/fusion_woocommerce/controllers/product_search.py b/fusion-woo-odoo/fusion_woocommerce/controllers/product_search.py index 2e293a4d..75757023 100644 --- a/fusion-woo-odoo/fusion_woocommerce/controllers/product_search.py +++ b/fusion-woo-odoo/fusion_woocommerce/controllers/product_search.py @@ -159,6 +159,7 @@ class WooProductSearchController(http.Controller): 'woo_product_name': m.woo_product_name or '', 'woo_sku': m.woo_sku or '', 'woo_product_type': m.woo_product_type or '', + 'woo_category_name': m.woo_category_name or '', } for m in maps ], diff --git a/fusion-woo-odoo/fusion_woocommerce/controllers/webhook.py b/fusion-woo-odoo/fusion_woocommerce/controllers/webhook.py index 7aa8cd4b..1636e7df 100644 --- a/fusion-woo-odoo/fusion_woocommerce/controllers/webhook.py +++ b/fusion-woo-odoo/fusion_woocommerce/controllers/webhook.py @@ -25,7 +25,7 @@ class WooWebhookController(http.Controller): # Simple in-memory rate limiter: {ip: [(timestamp, ...),]} _rate_tracker = defaultdict(list) - _RATE_LIMIT = 100 # max requests per minute + _RATE_LIMIT = 60 # max requests per minute (1/sec sustained) _RATE_WINDOW = 60 # seconds @classmethod diff --git a/fusion-woo-odoo/fusion_woocommerce/lib/woo_api_client.py b/fusion-woo-odoo/fusion_woocommerce/lib/woo_api_client.py index bd0856a3..93ec2933 100644 --- a/fusion-woo-odoo/fusion_woocommerce/lib/woo_api_client.py +++ b/fusion-woo-odoo/fusion_woocommerce/lib/woo_api_client.py @@ -2,17 +2,102 @@ import base64 import hashlib import hmac import logging +import threading import time import requests _logger = logging.getLogger(__name__) +# --------------------------------------------------------------------------- +# Shared circuit breaker state — one per WC base URL so all Odoo workers +# referencing the same WooCommerce store share the same breaker. +# --------------------------------------------------------------------------- +_circuit_breakers = {} +_cb_lock = threading.Lock() + + +class _CircuitBreaker: + """Per-host circuit breaker: CLOSED → OPEN after N failures, auto-resets + after a cooldown period to HALF_OPEN (allows one probe request).""" + + CLOSED = 'closed' + OPEN = 'open' + HALF_OPEN = 'half_open' + + def __init__(self, failure_threshold=5, cooldown_seconds=60): + self.failure_threshold = failure_threshold + self.cooldown_seconds = cooldown_seconds + self.state = self.CLOSED + self.consecutive_failures = 0 + self.last_failure_time = 0 + self._lock = threading.Lock() + + def record_success(self): + with self._lock: + self.consecutive_failures = 0 + self.state = self.CLOSED + + def record_failure(self): + with self._lock: + self.consecutive_failures += 1 + self.last_failure_time = time.monotonic() + if self.consecutive_failures >= self.failure_threshold: + self.state = self.OPEN + _logger.warning( + "Circuit breaker OPEN after %d consecutive failures — " + "blocking requests for %ds", + self.consecutive_failures, self.cooldown_seconds, + ) + + def allow_request(self): + with self._lock: + if self.state == self.CLOSED: + return True + elapsed = time.monotonic() - self.last_failure_time + if elapsed >= self.cooldown_seconds: + self.state = self.HALF_OPEN + _logger.info("Circuit breaker HALF_OPEN — allowing probe request") + return True + return False + + +class _TokenBucket: + """Simple token-bucket rate limiter. Tokens refill at *rate* per second + up to *capacity*. ``consume()`` blocks until a token is available.""" + + def __init__(self, rate, capacity): + self.rate = rate + self.capacity = capacity + self.tokens = capacity + self.last_refill = time.monotonic() + self._lock = threading.Lock() + + def consume(self): + while True: + with self._lock: + now = time.monotonic() + elapsed = now - self.last_refill + self.tokens = min(self.capacity, self.tokens + elapsed * self.rate) + self.last_refill = now + if self.tokens >= 1: + self.tokens -= 1 + return + time.sleep(0.1) + class WooApiClient: - """WooCommerce REST API v3 client wrapper.""" + """WooCommerce REST API v3 client wrapper with rate limiting and circuit + breaker protection.""" - def __init__(self, url, consumer_key, consumer_secret, api_version='wc/v3', timeout=30): + # Default: 3 requests/sec, burst up to 5. + # WooCommerce typically allows ~240 req/min (4/sec) so 3/sec is safe. + DEFAULT_RATE = 3 + DEFAULT_BURST = 5 + + def __init__(self, url, consumer_key, consumer_secret, + api_version='wc/v3', timeout=30, + rate_limit=None, burst_limit=None): self.base_url = url.rstrip('/') self.api_version = api_version self.timeout = timeout @@ -24,40 +109,105 @@ class WooApiClient: 'User-Agent': 'FusionWooCommerce/1.0', }) + rate = rate_limit or self.DEFAULT_RATE + burst = burst_limit or self.DEFAULT_BURST + self._bucket = _TokenBucket(rate, burst) + + with _cb_lock: + if self.base_url not in _circuit_breakers: + _circuit_breakers[self.base_url] = _CircuitBreaker( + failure_threshold=5, cooldown_seconds=60, + ) + self._breaker = _circuit_breakers[self.base_url] + def _url(self, endpoint): return f"{self.base_url}/wp-json/{self.api_version}/{endpoint}" def _request(self, method, endpoint, data=None, params=None, retries=3): url = self._url(endpoint) + + if not self._breaker.allow_request(): + raise ConnectionError( + "WooCommerce API circuit breaker is OPEN for %s — " + "too many consecutive failures. Retry later." % self.base_url + ) + last_exc = None for attempt in range(retries): + self._bucket.consume() try: response = self.session.request( - method, - url, - json=data, - params=params, + method, url, + json=data, params=params, timeout=self.timeout, ) - if response.status_code >= 400: - _logger.error( - "WC API %s %s returned %s: %s", - method, endpoint, response.status_code, response.text[:500], + + # --- Handle rate-limit response from WC / server --- + if response.status_code == 429: + retry_after = int(response.headers.get('Retry-After', 10)) + retry_after = min(retry_after, 120) + _logger.warning( + "WC API 429 on %s %s — backing off %ds (attempt %d/%d)", + method, endpoint, retry_after, attempt + 1, retries, ) - response.raise_for_status() + time.sleep(retry_after) + continue + + # --- Non-retryable client errors (400-499 except 429) --- + if 400 <= response.status_code < 500: + _logger.error( + "WC API %s %s returned %s (non-retryable): %s", + method, endpoint, response.status_code, + response.text[:500], + ) + self._breaker.record_success() + response.raise_for_status() + + # --- Server errors (500+) are retryable --- + if response.status_code >= 500: + _logger.warning( + "WC API %s %s returned %s (attempt %d/%d): %s", + method, endpoint, response.status_code, + attempt + 1, retries, response.text[:300], + ) + last_exc = requests.HTTPError(response=response) + wait = min(2 ** attempt * 2, 30) + time.sleep(wait) + continue + + self._breaker.record_success() return response.json() - except Exception as exc: + + except requests.exceptions.ConnectionError as exc: last_exc = exc - wait = 2 ** attempt + wait = min(2 ** attempt * 2, 30) _logger.warning( - "WooCommerce API %s %s failed (attempt %d/%d): %s — retrying in %ds", + "WC API connection error %s %s (attempt %d/%d): %s — " + "retrying in %ds", method, endpoint, attempt + 1, retries, exc, wait, ) - if attempt < retries - 1: - time.sleep(wait) + time.sleep(wait) + + except requests.exceptions.Timeout as exc: + last_exc = exc + wait = min(2 ** attempt * 2, 30) + _logger.warning( + "WC API timeout %s %s (attempt %d/%d) — retrying in %ds", + method, endpoint, attempt + 1, retries, wait, + ) + time.sleep(wait) + + except Exception as exc: + last_exc = exc + _logger.error( + "WC API unexpected error %s %s: %s", method, endpoint, exc, + ) + break + + self._breaker.record_failure() raise last_exc - # Convenience methods + # --- Convenience methods --- def get(self, endpoint, params=None): return self._request('GET', endpoint, params=params) @@ -71,7 +221,7 @@ class WooApiClient: def delete(self, endpoint): return self._request('DELETE', endpoint) - # Product endpoints + # --- Product endpoints --- def get_products(self, page=1, per_page=100, **kwargs): params = {'page': page, 'per_page': per_page, **kwargs} @@ -90,7 +240,7 @@ class WooApiClient: def create_product(self, data): return self.post('products', data) - # Attribute endpoints + # --- Attribute endpoints --- def get_product_attributes(self): return self.get('products/attributes', params={'per_page': 100}) @@ -99,12 +249,15 @@ class WooApiClient: return self.post('products/attributes', data) def get_attribute_terms(self, attribute_id, page=1, per_page=100): - return self.get(f'products/attributes/{attribute_id}/terms', params={'page': page, 'per_page': per_page}) + return self.get( + f'products/attributes/{attribute_id}/terms', + params={'page': page, 'per_page': per_page}, + ) def create_attribute_term(self, attribute_id, data): return self.post(f'products/attributes/{attribute_id}/terms', data) - # Variation endpoints + # --- Variation endpoints --- def create_product_variation(self, product_id, data): return self.post(f'products/{product_id}/variations', data) @@ -117,9 +270,12 @@ class WooApiClient: def batch_create_variations(self, product_id, variations_data): """Create multiple variations at once using WC batch endpoint.""" - return self.post(f'products/{product_id}/variations/batch', {'create': variations_data}) + return self.post( + f'products/{product_id}/variations/batch', + {'create': variations_data}, + ) - # Order endpoints + # --- Order endpoints --- def get_orders(self, page=1, per_page=100, **kwargs): params = {'page': page, 'per_page': per_page, **kwargs} @@ -131,7 +287,7 @@ class WooApiClient: def update_order(self, order_id, data): return self.put(f'orders/{order_id}', data) - # Customer endpoints + # --- Customer endpoints --- def get_customers(self, page=1, per_page=100, **kwargs): params = {'page': page, 'per_page': per_page, **kwargs} @@ -146,7 +302,7 @@ class WooApiClient: def update_customer(self, customer_id, data): return self.put(f'customers/{customer_id}', data) - # Webhook endpoints + # --- Webhook endpoints --- def create_webhook(self, data): return self.post('webhooks', data) @@ -157,12 +313,12 @@ class WooApiClient: def delete_webhook(self, webhook_id): return self.delete(f'webhooks/{webhook_id}') - # Tax endpoints + # --- Tax endpoints --- def get_tax_classes(self): return self.get('taxes/classes') - # Utility + # --- Utility --- def test_connection(self): try: @@ -174,16 +330,7 @@ class WooApiClient: @staticmethod def verify_webhook_signature(payload, signature, secret): - """Verify a WooCommerce webhook HMAC-SHA256 signature. - - Args: - payload (bytes): Raw request body bytes. - signature (str): Value of the X-WC-Webhook-Signature header. - secret (str): The webhook secret configured in WooCommerce. - - Returns: - bool: True if the signature matches, False otherwise. - """ + """Verify a WooCommerce webhook HMAC-SHA256 signature.""" if isinstance(payload, str): payload = payload.encode('utf-8') if isinstance(secret, str): diff --git a/fusion-woo-odoo/fusion_woocommerce/models/woo_instance.py b/fusion-woo-odoo/fusion_woocommerce/models/woo_instance.py index 2fcbdb91..75e7167a 100644 --- a/fusion-woo-odoo/fusion_woocommerce/models/woo_instance.py +++ b/fusion-woo-odoo/fusion_woocommerce/models/woo_instance.py @@ -365,6 +365,10 @@ class WooInstance(models.Model): 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, @@ -374,6 +378,8 @@ class WooInstance(models.Model): '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, @@ -451,6 +457,9 @@ class WooInstance(models.Model): '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() @@ -462,9 +471,11 @@ class WooInstance(models.Model): ('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: @@ -485,7 +496,14 @@ class WooInstance(models.Model): 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 @@ -589,8 +607,20 @@ class WooInstance(models.Model): self.ensure_one() client = self._get_client() page = 1 + consecutive_fetch_errors = 0 while True: - orders = client.get_orders(page=page, status='processing,on-hold,pending') + 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: @@ -644,37 +674,51 @@ class WooInstance(models.Model): # 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) - # 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 + # Create woo.order tracking record FIRST to prevent infinite retries + # if action_confirm or invoicing fails later. 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, }) + # 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 @@ -860,16 +904,41 @@ class WooInstance(models.Model): '|', ('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)), } - if product: - vals['product_id'] = product.id - # Tax mapping taxes = [] for tax_entry in line_item.get('taxes', []): @@ -891,15 +960,34 @@ class WooInstance(models.Model): 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, } @@ -967,29 +1055,26 @@ class WooInstance(models.Model): ('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: - # 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', @@ -999,13 +1084,11 @@ class WooInstance(models.Model): '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), }) @@ -1014,7 +1097,6 @@ class WooInstance(models.Model): '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, @@ -1023,15 +1105,22 @@ class WooInstance(models.Model): 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, ) - # 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), ) + 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.""" @@ -1074,11 +1163,11 @@ class WooInstance(models.Model): ('product_id', '!=', False), ]) + consecutive_errors = 0 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', @@ -1094,12 +1183,14 @@ class WooInstance(models.Model): '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, @@ -1108,6 +1199,13 @@ class WooInstance(models.Model): '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) @@ -1122,10 +1220,10 @@ class WooInstance(models.Model): ('woo_customer_id', '>', 0), ]) + consecutive_errors = 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 @@ -1146,6 +1244,7 @@ class WooInstance(models.Model): 'billing': billing_data, 'shipping': billing_data, }) + consecutive_errors = 0 cust.last_synced = fields.Datetime.now() self._log_sync( @@ -1153,6 +1252,7 @@ class WooInstance(models.Model): '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, @@ -1161,6 +1261,13 @@ class WooInstance(models.Model): '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.""" diff --git a/fusion-woo-odoo/fusion_woocommerce/models/woo_product_map.py b/fusion-woo-odoo/fusion_woocommerce/models/woo_product_map.py index 6e11771a..2df7c98d 100644 --- a/fusion-woo-odoo/fusion_woocommerce/models/woo_product_map.py +++ b/fusion-woo-odoo/fusion_woocommerce/models/woo_product_map.py @@ -29,6 +29,8 @@ class WooProductMap(models.Model): 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) @@ -312,6 +314,54 @@ class WooProductMap(models.Model): 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 # ------------------------------------------------------------------ diff --git a/fusion-woo-odoo/fusion_woocommerce/static/src/css/woo_styles.css b/fusion-woo-odoo/fusion_woocommerce/static/src/css/woo_styles.css index fa780b99..51cc58eb 100644 --- a/fusion-woo-odoo/fusion_woocommerce/static/src/css/woo_styles.css +++ b/fusion-woo-odoo/fusion_woocommerce/static/src/css/woo_styles.css @@ -1,144 +1,37 @@ /* ============================================================ - Fusion WooCommerce — Theme-Aware Styles - Uses Odoo's CSS custom properties for dark/light mode support. - Odoo 19 sets color_scheme cookie to "dark" or "bright" and - applies .o_dark on the body or uses Bootstrap variables. + Fusion WooCommerce — Layout-Only Styles + All colors are inherited from Odoo's compiled theme. + No CSS custom properties for colors — Odoo handles theming + via SCSS compilation, not Bootstrap's data-bs-theme. ============================================================ */ -/* ---------------------------------------------------------- - CSS Custom Properties — light defaults, dark overrides - ---------------------------------------------------------- */ -:root { - --woo-bg-primary: #ffffff; - --woo-bg-secondary: #f9fafb; - --woo-bg-tertiary: #f3f4f6; - --woo-bg-hover: #f3f4f6; - --woo-bg-selected: #ede9fe; - --woo-border: #e5e7eb; - --woo-border-light: #f3f4f6; - --woo-text-primary: #111827; - --woo-text-secondary: #374151; - --woo-text-muted: #6b7280; - --woo-text-faint: #9ca3af; - --woo-accent: #7c3aed; - --woo-accent-hover: #6d28d9; - --woo-accent-glow: rgba(124, 58, 237, 0.15); - --woo-success: #059669; - --woo-success-bg: #d1fae5; - --woo-success-text: #065f46; - --woo-warning: #d97706; - --woo-warning-bg: #fef3c7; - --woo-warning-text: #92400e; - --woo-danger: #dc2626; - --woo-danger-bg: #fee2e2; - --woo-danger-text: #991b1b; - --woo-info-bg: #dbeafe; - --woo-info-text: #1e40af; - --woo-card-shadow: 0 1px 3px rgba(0, 0, 0, 0.06); - --woo-card-shadow-hover: 0 4px 12px rgba(0, 0, 0, 0.1); - --woo-progress-bg: #e5e7eb; - --woo-spinner-track: #e5e7eb; - --woo-btn-secondary-bg: #ffffff; - --woo-btn-secondary-border: #d1d5db; - --woo-btn-secondary-hover: #f3f4f6; - --woo-input-bg: #ffffff; - --woo-input-border: #d1d5db; -} - -/* Dark mode — theme_detect.js reads Odoo's color_scheme cookie and sets - data-woo-theme="dark" on . We also include @media query as fallback. */ -html[data-woo-theme="dark"], -html[style*="color-scheme: dark"] { - --woo-bg-primary: #1e1e2e; - --woo-bg-secondary: #262637; - --woo-bg-tertiary: #2e2e42; - --woo-bg-hover: #33334a; - --woo-bg-selected: #3b2d6b; - --woo-border: #3c3c54; - --woo-border-light: #33334a; - --woo-text-primary: #e4e4e7; - --woo-text-secondary: #c4c4cc; - --woo-text-muted: #9ca3af; - --woo-text-faint: #6b7280; - --woo-accent: #a78bfa; - --woo-accent-hover: #8b5cf6; - --woo-accent-glow: rgba(167, 139, 250, 0.2); - --woo-success: #34d399; - --woo-success-bg: #064e3b; - --woo-success-text: #6ee7b7; - --woo-warning: #fbbf24; - --woo-warning-bg: #451a03; - --woo-warning-text: #fcd34d; - --woo-danger: #f87171; - --woo-danger-bg: #450a0a; - --woo-danger-text: #fca5a5; - --woo-info-bg: #1e3a5f; - --woo-info-text: #93c5fd; - --woo-card-shadow: 0 1px 3px rgba(0, 0, 0, 0.3); - --woo-card-shadow-hover: 0 4px 12px rgba(0, 0, 0, 0.4); - --woo-progress-bg: #3c3c54; - --woo-spinner-track: #3c3c54; - --woo-btn-secondary-bg: #2e2e42; - --woo-btn-secondary-border: #3c3c54; - --woo-btn-secondary-hover: #33334a; - --woo-input-bg: #2e2e42; - --woo-input-border: #3c3c54; -} - -/* ---------------------------------------------------------- - Status badges - ---------------------------------------------------------- */ -.woo-badge { - display: inline-block; - padding: 2px 10px; - border-radius: 12px; - font-size: 0.78rem; - font-weight: 600; - letter-spacing: 0.02em; -} -.woo-badge-mapped, .woo-badge-success { - background: var(--woo-success-bg); - color: var(--woo-success-text); -} -.woo-badge-unmapped { - background: var(--woo-bg-tertiary); - color: var(--woo-text-muted); -} -.woo-badge-conflict { - background: var(--woo-warning-bg); - color: var(--woo-warning-text); -} -.woo-badge-error, .woo-badge-failed { - background: var(--woo-danger-bg); - color: var(--woo-danger-text); -} - /* ---------------------------------------------------------- Tab navigation ---------------------------------------------------------- */ .woo-tabs { display: flex; gap: 4px; - border-bottom: 2px solid var(--woo-border); + border-bottom: 2px solid; + border-color: inherit; margin-bottom: 16px; } .woo-tab { padding: 8px 20px; cursor: pointer; font-weight: 500; - color: var(--woo-text-muted); + opacity: 0.6; + border: none; border-bottom: 2px solid transparent; margin-bottom: -2px; background: none; - border-top: none; - border-left: none; - border-right: none; - transition: color 0.15s, border-color 0.15s; + color: inherit; + transition: opacity 0.15s, border-color 0.15s; } -.woo-tab:hover { color: var(--woo-text-secondary); } +.woo-tab:hover { opacity: 0.85; } .woo-tab.active { - color: var(--woo-accent); - border-bottom-color: var(--woo-accent); + opacity: 1; + font-weight: 600; + border-bottom-color: currentColor; } /* ---------------------------------------------------------- @@ -149,34 +42,35 @@ html[style*="color-scheme: dark"] { align-items: center; gap: 12px; padding: 12px 16px; - background: var(--woo-bg-secondary); - border-bottom: 1px solid var(--woo-border); + border-bottom: 1px solid; + border-color: inherit; flex-wrap: wrap; } .woo-topbar select, .woo-topbar input { - border: 1px solid var(--woo-input-border); + border: 1px solid; + border-color: inherit; border-radius: 6px; padding: 6px 10px; font-size: 0.875rem; - background: var(--woo-input-bg); - color: var(--woo-text-primary); + background: inherit; + color: inherit; } .woo-stat { display: flex; flex-direction: column; align-items: center; padding: 4px 14px; - border-left: 1px solid var(--woo-border); + border-left: 1px solid; + border-color: inherit; } .woo-stat-value { font-size: 1.25rem; font-weight: 700; - color: var(--woo-text-primary); } .woo-stat-label { font-size: 0.7rem; - color: var(--woo-text-muted); + opacity: 0.6; text-transform: uppercase; letter-spacing: 0.04em; } @@ -192,57 +86,59 @@ html[style*="color-scheme: dark"] { .woo-search-wrap .woo-search-icon { position: absolute; left: 10px; - color: var(--woo-text-faint); + opacity: 0.5; font-size: 14px; pointer-events: none; } .woo-search-input { padding: 6px 10px 6px 32px; - border: 1px solid var(--woo-input-border); + border: 1px solid; + border-color: inherit; border-radius: 6px; font-size: 0.875rem; width: 240px; - background: var(--woo-input-bg); - color: var(--woo-text-primary); - transition: border-color 0.15s; + background: inherit; + color: inherit; } .woo-search-input:focus { outline: none; - border-color: var(--woo-accent); - box-shadow: 0 0 0 2px var(--woo-accent-glow); -} -.woo-search-input::placeholder { - color: var(--woo-text-faint); + box-shadow: 0 0 0 2px rgba(0, 0, 0, 0.1); } +.woo-search-input::placeholder { opacity: 0.5; } /* ---------------------------------------------------------- Tables ---------------------------------------------------------- */ .woo-table-wrap { overflow-x: auto; + border: 1px solid; + border-color: inherit; + border-radius: 6px; } .woo-table { width: 100%; border-collapse: collapse; font-size: 0.875rem; + margin-bottom: 0; } .woo-table th { - background: var(--woo-bg-tertiary); padding: 10px 12px; text-align: left; font-weight: 600; - color: var(--woo-text-secondary); - border-bottom: 2px solid var(--woo-border); + border-bottom: 2px solid; + border-color: inherit; white-space: nowrap; + opacity: 0.85; } .woo-table td { padding: 9px 12px; - border-bottom: 1px solid var(--woo-border-light); - color: var(--woo-text-secondary); + border-bottom: 1px solid; + border-color: inherit; vertical-align: middle; } -.woo-table tr:hover td { background: var(--woo-bg-hover); } -.woo-table tr.selected td { background: var(--woo-bg-selected); } +.woo-table tbody tr:last-child td { border-bottom: none; } +.woo-table tr:hover td { opacity: 0.9; } +.woo-table tr.selected td { font-weight: 500; } /* ---------------------------------------------------------- Split view (unmatched tab) @@ -254,17 +150,16 @@ html[style*="color-scheme: dark"] { align-items: start; } .woo-split-panel { - border: 1px solid var(--woo-border); + border: 1px solid; + border-color: inherit; border-radius: 8px; overflow: hidden; - background: var(--woo-bg-primary); } .woo-split-panel-header { - background: var(--woo-bg-tertiary); padding: 10px 14px; font-weight: 600; - color: var(--woo-text-secondary); - border-bottom: 1px solid var(--woo-border); + border-bottom: 1px solid; + border-color: inherit; display: flex; justify-content: space-between; align-items: center; @@ -276,7 +171,7 @@ html[style*="color-scheme: dark"] { justify-content: center; padding-top: 60px; gap: 8px; - color: var(--woo-text-faint); + opacity: 0.5; font-size: 1.2rem; } .woo-split-list { @@ -286,13 +181,15 @@ html[style*="color-scheme: dark"] { .woo-split-item { padding: 10px 14px; cursor: pointer; - border-bottom: 1px solid var(--woo-border-light); - transition: background 0.1s; + border-bottom: 1px solid; + border-color: inherit; + transition: opacity 0.1s; } -.woo-split-item:hover { background: var(--woo-bg-hover); } -.woo-split-item.selected { background: var(--woo-bg-selected); } -.woo-split-item-name { font-weight: 500; color: var(--woo-text-primary); } -.woo-split-item-sub { font-size: 0.75rem; color: var(--woo-text-muted); margin-top: 1px; } +.woo-split-item:last-child { border-bottom: none; } +.woo-split-item:hover { opacity: 0.8; } +.woo-split-item.selected { font-weight: 600; } +.woo-split-item-name { font-weight: 500; } +.woo-split-item-sub { font-size: 0.75rem; opacity: 0.6; margin-top: 1px; } /* ---------------------------------------------------------- Map actions bar @@ -302,52 +199,23 @@ html[style*="color-scheme: dark"] { gap: 8px; padding: 10px 0 14px; flex-wrap: wrap; + border-bottom: 1px solid; + border-color: inherit; + margin-bottom: 14px; } -/* ---------------------------------------------------------- - Buttons - ---------------------------------------------------------- */ -.woo-btn { - padding: 6px 14px; - border-radius: 6px; - font-size: 0.875rem; - font-weight: 500; - cursor: pointer; - border: 1px solid transparent; - transition: background 0.15s, border-color 0.15s; -} -.woo-btn-primary { background: var(--woo-accent); color: #fff; border-color: var(--woo-accent); } -.woo-btn-primary:hover { background: var(--woo-accent-hover); } -.woo-btn-success { background: var(--woo-success); color: #fff; border-color: var(--woo-success); } -.woo-btn-success:hover { background: #047857; } -.woo-btn-warning { background: var(--woo-warning); color: #fff; border-color: var(--woo-warning); } -.woo-btn-warning:hover { background: #b45309; } -.woo-btn-danger { background: var(--woo-danger); color: #fff; border-color: var(--woo-danger); } -.woo-btn-danger:hover { background: #b91c1c; } -.woo-btn-secondary { - background: var(--woo-btn-secondary-bg); - color: var(--woo-text-secondary); - border-color: var(--woo-btn-secondary-border); -} -.woo-btn-secondary:hover { background: var(--woo-btn-secondary-hover); } -.woo-btn-sm { padding: 3px 10px; font-size: 0.8rem; } -.woo-btn:disabled { opacity: 0.5; cursor: not-allowed; } - /* ---------------------------------------------------------- Dashboard cards ---------------------------------------------------------- */ -.woo-dashboard { - padding: 20px; -} +.woo-dashboard { padding: 20px; } .woo-dashboard-title { font-size: 1.4rem; font-weight: 700; - color: var(--woo-text-primary); margin-bottom: 4px; } .woo-dashboard-subtitle { font-size: 0.875rem; - color: var(--woo-text-muted); + opacity: 0.6; margin-bottom: 24px; } .woo-cards { @@ -357,57 +225,43 @@ html[style*="color-scheme: dark"] { margin-bottom: 24px; } .woo-card { - background: var(--woo-bg-primary); - border: 1px solid var(--woo-border); + border: 1px solid; + border-color: inherit; border-radius: 10px; padding: 20px; display: flex; flex-direction: column; gap: 6px; - box-shadow: var(--woo-card-shadow); - transition: box-shadow 0.15s; } -.woo-card:hover { box-shadow: var(--woo-card-shadow-hover); } .woo-card-clickable { cursor: pointer; } -.woo-card-icon { - font-size: 1.6rem; - margin-bottom: 4px; -} -.woo-card-value { - font-size: 2rem; - font-weight: 700; - color: var(--woo-text-primary); - line-height: 1; -} +.woo-card-clickable:hover { opacity: 0.85; } +.woo-card-icon { font-size: 1.6rem; margin-bottom: 4px; } +.woo-card-value { font-size: 2rem; font-weight: 700; line-height: 1; } .woo-card-label { font-size: 0.8rem; - color: var(--woo-text-muted); + opacity: 0.6; text-transform: uppercase; letter-spacing: 0.04em; } -.woo-card-sub { - font-size: 0.78rem; - color: var(--woo-text-faint); - margin-top: 2px; -} -.woo-card-pending { border-left: 4px solid #f59e0b; } -.woo-card-errors { border-left: 4px solid #ef4444; } -.woo-card-mapped { border-left: 4px solid #10b981; } -.woo-card-sync { border-left: 4px solid #6366f1; } +.woo-card-sub { font-size: 0.78rem; opacity: 0.5; margin-top: 2px; } +.woo-card-pending { border-left-width: 4px; border-left-style: solid; } +.woo-card-errors { border-left-width: 4px; border-left-style: solid; } +.woo-card-mapped { border-left-width: 4px; border-left-style: solid; } +.woo-card-sync { border-left-width: 4px; border-left-style: solid; } /* ---------------------------------------------------------- Progress bar ---------------------------------------------------------- */ .woo-progress-wrap { - background: var(--woo-progress-bg); border-radius: 6px; height: 8px; overflow: hidden; margin-top: 6px; + opacity: 0.15; + background: currentColor; } .woo-progress-bar { height: 100%; - background: linear-gradient(90deg, #10b981, #059669); border-radius: 6px; transition: width 0.4s ease; } @@ -420,15 +274,16 @@ html[style*="color-scheme: dark"] { align-items: center; justify-content: center; padding: 40px; - color: var(--woo-text-muted); + opacity: 0.6; gap: 10px; font-size: 0.9rem; } .woo-spinner { width: 20px; height: 20px; - border: 2px solid var(--woo-spinner-track); - border-top-color: var(--woo-accent); + border: 2px solid currentColor; + opacity: 0.3; + border-top-color: currentColor; border-radius: 50%; animation: woo-spin 0.7s linear infinite; } @@ -440,7 +295,7 @@ html[style*="color-scheme: dark"] { .woo-empty { text-align: center; padding: 48px 20px; - color: var(--woo-text-faint); + opacity: 0.5; } .woo-empty-icon { font-size: 2.5rem; margin-bottom: 10px; } .woo-empty-text { font-size: 0.9rem; } @@ -461,10 +316,10 @@ html[style*="color-scheme: dark"] { .woo-section-title { font-size: 1rem; font-weight: 600; - color: var(--woo-text-secondary); margin-bottom: 12px; padding-bottom: 8px; - border-bottom: 1px solid var(--woo-border-light); + border-bottom: 1px solid; + border-color: inherit; } /* ---------------------------------------------------------- @@ -477,82 +332,14 @@ html[style*="color-scheme: dark"] { } /* ---------------------------------------------------------- - Theme-aware utility classes + Utility classes ---------------------------------------------------------- */ -.woo-text-muted { color: var(--woo-text-muted) !important; } -.woo-text-faint { color: var(--woo-text-faint) !important; } - -/* Inline code snippets (SKU etc.) */ .woo-code { font-family: monospace; font-size: 0.85em; padding: 1px 6px; border-radius: 4px; - background: var(--woo-bg-tertiary); - color: var(--woo-text-secondary); -} - -/* ---------------------------------------------------------- - Client action wrapper — ensure background matches theme - ---------------------------------------------------------- */ -.o_action.o_client_action .woo-dashboard, -.o_action.o_client_action .p-3 { - background: var(--woo-bg-primary); - color: var(--woo-text-primary); - min-height: 100%; -} - -/* ---------------------------------------------------------- - Form view overrides — theme-aware inputs in Odoo forms - ---------------------------------------------------------- */ -.o_form_view .woo-table th { - background: var(--woo-bg-tertiary); - color: var(--woo-text-secondary); -} -.o_form_view .woo-table td { - color: var(--woo-text-secondary); - border-bottom-color: var(--woo-border-light); -} - -/* ---------------------------------------------------------- - Checkbox overrides for dark mode - ---------------------------------------------------------- */ -.woo-table input[type="checkbox"], -.woo-topbar input[type="checkbox"] { - accent-color: var(--woo-accent); -} - -/* ---------------------------------------------------------- - Select dropdown — theme aware - ---------------------------------------------------------- */ -.woo-topbar select { - background: var(--woo-input-bg); - color: var(--woo-text-primary); - border-color: var(--woo-input-border); -} -.woo-topbar select option { - background: var(--woo-bg-primary); - color: var(--woo-text-primary); -} - -/* ---------------------------------------------------------- - Odoo native overrides for our views - These ensure Odoo's own notebook/tab/statusbar elements - inside our module's form views respect the theme. - ---------------------------------------------------------- */ -.o_form_view .o_notebook .nav-link { - color: var(--woo-text-muted); -} -.o_form_view .o_notebook .nav-link.active { - color: var(--woo-text-primary); -} - -/* ---------------------------------------------------------- - Ensure strong/bold text uses theme colour - ---------------------------------------------------------- */ -.woo-table strong, -.woo-card strong { - color: var(--woo-text-primary); + opacity: 0.8; } /* ---------------------------------------------------------- @@ -564,102 +351,58 @@ html[style*="color-scheme: dark"] { justify-content: center; gap: 12px; padding: 12px 0; + border-top: 1px solid; + border-color: inherit; + margin-top: 4px; } .woo-pagination-info { font-size: 0.85rem; - color: var(--woo-text-muted); + opacity: 0.6; } /* ---------------------------------------------------------- - Icon buttons (price sync arrows) + Icon buttons (sync arrows) ---------------------------------------------------------- */ .woo-btn-icon { background: none; border: none; cursor: pointer; padding: 2px 4px; - color: var(--woo-text-muted); + opacity: 0.5; font-size: 0.8rem; border-radius: 4px; - transition: color 0.15s, background 0.15s; -} -.woo-btn-icon:hover { - color: var(--woo-accent); - background: var(--woo-bg-hover); + color: inherit; + transition: opacity 0.15s; } +.woo-btn-icon:hover { opacity: 1; } + /* Product link to WooCommerce */ -.woo-product-link { - color: var(--woo-accent); - text-decoration: none; - transition: color 0.15s; -} -.woo-product-link:hover { - color: var(--woo-accent-hover); - text-decoration: underline; -} -.woo-external-icon { - font-size: 0.7rem; - opacity: 0.5; -} -.woo-product-link:hover .woo-external-icon { - opacity: 1; -} +.woo-product-link { text-decoration: none; } +.woo-product-link:hover { text-decoration: underline; } +.woo-external-icon { font-size: 0.7rem; opacity: 0.5; } +.woo-product-link:hover .woo-external-icon { opacity: 1; } /* Sale price highlight */ -.woo-sale-price { - color: var(--woo-success); - font-weight: 600; -} - -.woo-price-sync-col { - width: 60px; - white-space: nowrap; -} +.woo-sale-price { font-weight: 600; } +.woo-price-sync-col { width: 60px; white-space: nowrap; } /* ---------------------------------------------------------- Editable price cells ---------------------------------------------------------- */ -.woo-editable-cell { - cursor: pointer; - position: relative; -} -.woo-editable-cell:hover { - background: var(--woo-bg-hover) !important; -} -.woo-margin-cell { - font-weight: 600; - color: var(--woo-success); -} +.woo-editable-cell { cursor: pointer; position: relative; } +.woo-editable-cell:hover { opacity: 0.8; } +.woo-margin-cell { font-weight: 600; } .woo-edit-input { width: 90px; padding: 2px 6px; - border: 1px solid var(--woo-accent); + border: 2px solid; + border-color: inherit; border-radius: 4px; font-size: 0.85rem; text-align: right; - background: var(--woo-input-bg); - color: var(--woo-text-primary); + background: inherit; + color: inherit; outline: none; - box-shadow: 0 0 0 2px var(--woo-accent-glow); -} - -/* Category filter dropdown */ -.woo-filter-select { - padding: 3px 8px; - border: 1px solid var(--woo-input-border); - border-radius: 4px; - font-size: 0.8rem; - background: var(--woo-input-bg); - color: var(--woo-text-primary); - max-width: 200px; -} -.woo-filter-select option { - background: var(--woo-bg-primary); - color: var(--woo-text-primary); -} - -.woo-edit-input-text { - text-align: left; - width: 120px; } +.woo-edit-input-text { text-align: left; width: 120px; } diff --git a/fusion-woo-odoo/fusion_woocommerce/static/src/js/product_mapping.js b/fusion-woo-odoo/fusion_woocommerce/static/src/js/product_mapping.js index 5c779696..2fdaa2ba 100644 --- a/fusion-woo-odoo/fusion_woocommerce/static/src/js/product_mapping.js +++ b/fusion-woo-odoo/fusion_woocommerce/static/src/js/product_mapping.js @@ -492,7 +492,38 @@ export class ProductMapping extends Component { } async createInOdoo(wooMapId) { - this.notification.add("Create in Odoo queued.", { type: "info" }); + if (!this.state.instanceId) { + this.notification.add("Please select an instance first.", { type: "warning" }); + return; + } + try { + const result = await rpc("/web/dataset/call_kw", { + model: "woo.product.map", + method: "action_create_in_odoo", + args: [[wooMapId]], + kwargs: {}, + }); + if (!result || !result.product_id) { + this.notification.add("Failed to create product.", { type: "danger" }); + return; + } + this.notification.add("Product created and mapped. Opening for editing…", { type: "success" }); + this.actionService.doAction({ + type: 'ir.actions.act_window', + res_model: 'product.product', + res_id: result.product_id, + views: [[false, 'form']], + target: 'new', + context: { form_view_initial_mode: 'edit' }, + }, { + onClose: async () => { + await this._refreshAll(); + }, + }); + } catch (err) { + console.error("[ProductMapping] createInOdoo error:", err); + this.notification.add(err.message || "Failed to create product in Odoo.", { type: "danger" }); + } } async ignoreWoo(wooMapId) { diff --git a/fusion-woo-odoo/fusion_woocommerce/static/src/js/theme_detect.js b/fusion-woo-odoo/fusion_woocommerce/static/src/js/theme_detect.js deleted file mode 100644 index 681c3906..00000000 --- a/fusion-woo-odoo/fusion_woocommerce/static/src/js/theme_detect.js +++ /dev/null @@ -1,25 +0,0 @@ -/** @odoo-module **/ - -/** - * Theme detection for Fusion WooCommerce. - * - * Odoo 19 stores dark mode preference in the "color_scheme" cookie ("dark" or "bright"). - * It also sets `color-scheme: dark` on .o_web_client via SCSS. - * - * This script reads the cookie and adds a `data-woo-theme="dark"` attribute on - * so our CSS can target it reliably. It also observes for changes (user toggles theme). - */ -import { cookie } from "@web/core/browser/cookie"; - -function applyTheme() { - const scheme = cookie.get("color_scheme"); - const isDark = scheme === "dark"; - document.documentElement.setAttribute("data-woo-theme", isDark ? "dark" : "light"); -} - -// Apply on load -applyTheme(); - -// Re-apply periodically in case user toggles theme mid-session -// (Odoo doesn't fire a DOM event for this, so we poll the cookie) -setInterval(applyTheme, 2000); diff --git a/fusion-woo-odoo/fusion_woocommerce/static/src/xml/dashboard.xml b/fusion-woo-odoo/fusion_woocommerce/static/src/xml/dashboard.xml index 2eb2df4c..3c4e5337 100644 --- a/fusion-woo-odoo/fusion_woocommerce/static/src/xml/dashboard.xml +++ b/fusion-woo-odoo/fusion_woocommerce/static/src/xml/dashboard.xml @@ -23,7 +23,7 @@