From 2a363c6b406cbc07bed9d591c9dab16fc8dc6c22 Mon Sep 17 00:00:00 2001 From: gsinghpal Date: Thu, 2 Apr 2026 03:30:02 -0400 Subject: [PATCH] changes --- fusion-woo-odoo/check_products.sh | 11 + fusion-woo-odoo/fusion-woodoo.zip | Bin 39889 -> 0 bytes .../fusion_woocommerce/__manifest__.py | 1 - .../controllers/product_search.py | 1 + .../fusion_woocommerce/controllers/webhook.py | 2 +- .../fusion_woocommerce/lib/woo_api_client.py | 221 +++++++-- .../fusion_woocommerce/models/woo_instance.py | 163 ++++-- .../models/woo_product_map.py | 50 ++ .../static/src/css/woo_styles.css | 469 ++++-------------- .../static/src/js/product_mapping.js | 33 +- .../static/src/js/theme_detect.js | 25 - .../static/src/xml/dashboard.xml | 24 +- .../static/src/xml/product_mapping.xml | 77 +-- .../views/woo_product_map_views.xml | 3 + .../wizard/woo_product_fetch.py | 6 + 15 files changed, 580 insertions(+), 506 deletions(-) create mode 100644 fusion-woo-odoo/check_products.sh delete mode 100644 fusion-woo-odoo/fusion-woodoo.zip delete mode 100644 fusion-woo-odoo/fusion_woocommerce/static/src/js/theme_detect.js 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 fc9aa8ecb111b11c831ec873f35b7a43518d6711..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 39889 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 ZlLe81gAoJ-ap2DqDR4a>XchtC{{hg9EC2ui 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 @@
-
🛒
@@ -32,7 +32,7 @@
-
+
🔄
Last Sync
@@ -45,7 +45,7 @@
-
⚠️
@@ -54,7 +54,7 @@
-
+
🔗
% @@ -75,19 +75,19 @@
Quick Actions
- - - - @@ -111,13 +111,13 @@ - Connected + Connected - Error + Error - Draft + Draft @@ -125,7 +125,7 @@ - Never + Never diff --git a/fusion-woo-odoo/fusion_woocommerce/static/src/xml/product_mapping.xml b/fusion-woo-odoo/fusion_woocommerce/static/src/xml/product_mapping.xml index 8b43c3b1..83920280 100644 --- a/fusion-woo-odoo/fusion_woocommerce/static/src/xml/product_mapping.xml +++ b/fusion-woo-odoo/fusion_woocommerce/static/src/xml/product_mapping.xml @@ -36,15 +36,15 @@ - - - @@ -82,17 +82,17 @@
@@ -105,26 +105,26 @@ endpoint="'/woo/search/mapped'" t-props="{ instanceId: state.instanceId, onResults: onMappedResults.bind(this), placeholder: 'Search mapped products…' }" /> - - - - - -
@@ -211,8 +211,8 @@ -
- @@ -315,7 +315,7 @@ Page of ( total) - @@ -329,13 +329,13 @@
- - + Select one Odoo product and one WooCommerce product to map them. @@ -347,7 +347,7 @@
Odoo Products
- - @@ -404,7 +404,7 @@ Page of ( total) - @@ -413,7 +413,7 @@
- +
@@ -439,14 +439,15 @@
SKU: · + ·
- - @@ -455,7 +456,7 @@
- @@ -463,7 +464,7 @@ Page of ( total) - @@ -474,7 +475,7 @@
- @@ -494,11 +495,11 @@
- - @@ -519,7 +520,7 @@ - + @@ -528,11 +529,11 @@
- - diff --git a/fusion-woo-odoo/fusion_woocommerce/views/woo_product_map_views.xml b/fusion-woo-odoo/fusion_woocommerce/views/woo_product_map_views.xml index 2558e186..f75c2739 100644 --- a/fusion-woo-odoo/fusion_woocommerce/views/woo_product_map_views.xml +++ b/fusion-woo-odoo/fusion_woocommerce/views/woo_product_map_views.xml @@ -12,6 +12,7 @@ + @@ -41,6 +42,8 @@ + + diff --git a/fusion-woo-odoo/fusion_woocommerce/wizard/woo_product_fetch.py b/fusion-woo-odoo/fusion_woocommerce/wizard/woo_product_fetch.py index de64eeee..ab159832 100644 --- a/fusion-woo-odoo/fusion_woocommerce/wizard/woo_product_fetch.py +++ b/fusion-woo-odoo/fusion_woocommerce/wizard/woo_product_fetch.py @@ -104,6 +104,10 @@ class WooProductFetch(models.TransientModel): is_variation = wc_product.get('_is_variation', False) parent_id = wc_product.get('_parent_id') or wc_product.get('parent_id') or 0 + wc_categories = wc_product.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 '' + map_vals = { 'instance_id': instance.id, 'woo_product_id': woo_id, @@ -112,6 +116,8 @@ class WooProductFetch(models.TransientModel): 'woo_product_type': woo_type if not is_variation else 'variable', 'is_variation': is_variation, 'woo_parent_id': parent_id, + 'woo_category_id': wc_cat_id, + 'woo_category_name': wc_cat_name, 'company_id': instance.company_id.id, }