# -*- coding: utf-8 -*- from odoo import models, fields, api, _ from odoo.exceptions import UserError import requests import base64 import logging _logger = logging.getLogger(__name__) class ProductTemplate(models.Model): _inherit = 'product.template' @api.constrains('shopify_published', 'categ_id') def _check_shopify_published_category(self): for record in self: if record.shopify_published and record.categ_id: if 'Custom' in record.categ_id.name or 'Insumo' in record.categ_id.name: raise UserError("Los productos de la categoría Custom o Insumos no pueden ser publicados en Shopify.") # ── IDs de Shopify (ya existían) ───────────────────────── shopify_id = fields.Char( string='ID de Shopify (Template)', copy=False, index=True, readonly=True, ) shopify_compare_at_price = fields.Float( string='Precio de Comparación (Shopify)', copy=False, help="Precio original (tachado) en Shopify. El precio de venta será el precio público de Odoo.", ) # ── Campos nuevos: control de publicación ───────────────── shopify_published = fields.Boolean( string='Publicado en Shopify', default=False, copy=False, help="Si está activo, este producto existe en Shopify y se mantiene sincronizado automáticamente.", ) shopify_published_online = fields.Boolean( string='Disponible en Venta en Línea', default=True, copy=True, help="Si está marcado, el producto se publicará en la Tienda Online de Shopify.", ) shopify_published_pos = fields.Boolean( string='Disponible en Punto de Venta (POS)', default=True, copy=True, help="Si está marcado, el producto se publicará en el Point of Sale (POS) de Shopify.", ) shopify_config_id = fields.Many2one( 'shopify.config', string='Configuración Shopify', copy=False, help="Tienda de Shopify donde se publica este producto. Si se deja vacío, se usa la primera configuración activa.", ) shopify_status = fields.Selection( selection=[ ('draft', 'Borrador (draft)'), ('active', 'Activo (active)'), ], string='Estado en Shopify', default='draft', copy=False, help="Estado de publicación de este producto en Shopify.", ) shopify_synced_at = fields.Datetime( string='Primera sincronización', copy=False, readonly=True, help="Fecha en que el producto fue importado/sincronizado por primera vez desde Shopify.", ) shopify_last_pushed_at = fields.Datetime( string='Última actualización a Shopify', copy=False, readonly=True, help="Fecha de la última vez que se envió una actualización de este producto a Shopify.", ) shopify_sync_status = fields.Selection( selection=[ ('pending', '⏳ Pendiente'), ('synced', '✅ Sincronizado'), ('error', '❌ Error'), ], string='Estado Shopify', default='pending', copy=False, readonly=True, ) shopify_sync_error = fields.Char( string='Último error Shopify', copy=False, readonly=True, ) shopify_publication_ids = fields.One2many( 'shopify.product.publication', 'product_tmpl_id', string='Canales de Venta Shopify', copy=False, ) # ── Auditoría: tracking de cambios (ya existían) ───────── list_price = fields.Float(tracking=True) standard_price = fields.Float(tracking=True) default_code = fields.Char(tracking=True) barcode = fields.Char(tracking=True) # ────────────────────────────────────────────────────────── # HELPER: obtener la config de Shopify activa # ────────────────────────────────────────────────────────── def _get_shopify_config(self): """ Devuelve la shopify.config a usar para este producto. Prioridad: campo shopify_config_id → primera config confirmada del sistema. """ self.ensure_one() config = self.shopify_config_id if not config: config = self.env['shopify.config'].search( [('state', '=', 'confirmed')], limit=1 ) if not config: raise UserError( "No hay una configuración de Shopify activa.\n" "Ve a Shopify Connect → Configuración y confirma una tienda." ) return config # ────────────────────────────────────────────────────────── # ACCIÓN PRINCIPAL: Publicar / Actualizar en Shopify # ────────────────────────────────────────────────────────── def action_push_to_shopify(self): """ Botón manual: crea o actualiza el producto en Shopify. - Si shopify_id está vacío → POST (crear nuevo producto en Shopify). - Si shopify_id tiene valor → PUT (actualizar el existente). Maneja variantes e imagen principal. """ self.ensure_one() if self.categ_id and ('Custom' in self.categ_id.name or 'Insumo' in self.categ_id.name): raise UserError("No se permite exportar a Shopify productos de la categoría Custom o Insumo.") config = self._get_shopify_config() token = config._ensure_access_token() shop_domain = (config.shop_url or '').strip().lower() if '://' in shop_domain: shop_domain = shop_domain.split('://')[1].split('/')[0] headers = { 'X-Shopify-Access-Token': token, 'Content-Type': 'application/json', } api_base = f"https://{shop_domain}/admin/api/2024-01" # ── Respaldo de Barcodes desde Shopify ────────────────── # Si ya existe en Shopify, consultamos el producto actual para recuperar barcodes. # Esto evita que Odoo borre barcodes en Shopify si localmente estaban vacíos. if self.shopify_id: try: check_url = f"{api_base}/products/{self.shopify_id}.json" check_resp = requests.get(check_url, headers=headers, timeout=15) if check_resp.status_code == 200: prod_data = check_resp.json().get('product', {}) shopify_barcodes = {} for var in prod_data.get('variants', []): v_id = str(var.get('id')) barcode = var.get('barcode') if barcode: shopify_barcodes[v_id] = barcode # Actualizar variantes de Odoo con el barcode de Shopify si en Odoo está vacío for v in self.product_variant_ids: s_var_id = v.shopify_variant_id if s_var_id and s_var_id in shopify_barcodes and not v.barcode: v.sudo().write({'barcode': shopify_barcodes[s_var_id]}) except Exception as e_bc: _logger.warning("[Shopify Push] No se pudieron resguardar los barcodes de Shopify: %s", str(e_bc)) # ── Construir payload ───────────────────────────────── is_create = not bool(self.shopify_id) payload = self._build_shopify_product_payload(is_create=is_create) warning_messages = [] try: if self.shopify_id: # ── UPDATE ──────────────────────────────────── # CANDADO PRO: Para evitar errores de Shopify 422 con opciones ligadas a Metafields, # extraemos las variantes del payload del producto y las actualizamos individualmente. variants_payload = payload.pop('variants', []) url = f"{api_base}/products/{self.shopify_id}.json" resp = requests.put(url, headers=headers, json={'product': payload}, timeout=25) # Actualizar variantes individualmente has_new_variants = False if resp.status_code in (200, 201): for var_data in variants_payload: v_id = var_data.get('id') if v_id: v_url = f"{api_base}/variants/{v_id}.json" v_resp = requests.put(v_url, headers=headers, json={'variant': var_data}, timeout=15) if v_resp.status_code not in (200, 201): _logger.warning("[Shopify Push] Error actualizando variante %s: %s", v_id, v_resp.text[:200]) warning_messages.append(f"Variante {v_id} no actualizada.") else: # Si no tiene ID, es una nueva variante, la agregamos al producto v_url = f"{api_base}/products/{self.shopify_id}/variants.json" v_resp = requests.post(v_url, headers=headers, json={'variant': var_data}, timeout=15) if v_resp.status_code not in (200, 201): try: actual_error = v_resp.json().get('errors', v_resp.text[:200]) except Exception: actual_error = v_resp.text[:200] _logger.warning("[Shopify Push] Error creando variante: %s", actual_error) warning_messages.append(f"No se pudo crear la variante (SKU: {var_data.get('sku')}). Shopify respondió: {actual_error}") else: has_new_variants = True # Siempre volver a obtener el producto para sincronizar IDs de variantes # por si falló la creación porque la variante ya existía en Shopify if True: # Refetch product data to get the new variant IDs fetch_url = f"{api_base}/products/{self.shopify_id}.json" fetch_resp = requests.get(fetch_url, headers=headers, timeout=15) if fetch_resp.status_code == 200: product_data = fetch_resp.json().get('product', {}) else: product_data = resp.json().get('product', {}) else: product_data = resp.json().get('product', {}) else: # ── CREATE ──────────────────────────────────── url = f"{api_base}/products.json" resp = requests.post(url, headers=headers, json={'product': payload}, timeout=25) product_data = resp.json().get('product', {}) if resp.status_code not in (200, 201): error_msg = f"HTTP {resp.status_code}: {resp.text[:300]}" self.write({ 'shopify_sync_status': 'error', 'shopify_sync_error': error_msg, }) raise UserError( f"Error al publicar '{self.name}' en Shopify:\n{error_msg}" ) new_shopify_id = str(product_data.get('id', '')) # ── Guardar IDs de variantes en Odoo ───────────── self._sync_variant_ids_from_shopify(product_data) # ── Sincronizar stock inicial a Shopify ─────────── if config: try: config._sync_variants_stock_to_shopify(self.product_variant_ids) except Exception as e: _logger.warning("[Shopify Push] Excepción sincronizando stock de variantes: %s", str(e)) # ── Subir imagen si tiene (SOLO AL CREAR) ───────── # CANDADO PRO: No sobreescribir imágenes de Shopify en actualizaciones if self.image_1920 and new_shopify_id and is_create: self._push_image_to_shopify(api_base, new_shopify_id, headers) # ── Sincronizar canales de venta (Online vs POS) ── if new_shopify_id: self._sync_shopify_channels(api_base, headers, new_shopify_id) now = fields.Datetime.now() write_vals = { 'shopify_id': new_shopify_id, 'shopify_published': True, 'shopify_config_id': config.id, 'shopify_last_pushed_at': now, 'shopify_sync_status': 'synced', 'shopify_sync_error': False, } # Solo escribir shopify_synced_at la primera vez (cuando se crea) if is_create: write_vals['shopify_synced_at'] = now self.write(write_vals) action_label = "creado" if not self.shopify_id else "actualizado" _logger.info("[Shopify Push] Producto '%s' %s. ID Shopify: %s", self.name, action_label, new_shopify_id) final_message = f"'{self.name}' fue {action_label} correctamente en Shopify." if warning_messages: final_message += "\n\n⚠️ ADVERTENCIAS:\n" + "\n".join(warning_messages) return { 'type': 'ir.actions.client', 'tag': 'display_notification', 'params': { 'title': '✅ Publicado' if not warning_messages else '⚠️ Publicado con advertencias', 'message': final_message, 'type': 'success' if not warning_messages else 'warning', 'sticky': bool(warning_messages), } } except requests.exceptions.Timeout: self.write({'shopify_sync_status': 'error', 'shopify_sync_error': 'Timeout al conectar con Shopify'}) raise UserError("Timeout: Shopify tardó demasiado en responder. Intenta de nuevo.") except UserError: raise except Exception as e: self.write({'shopify_sync_status': 'error', 'shopify_sync_error': str(e)[:200]}) raise UserError(f"Error inesperado: {str(e)}") # ────────────────────────────────────────────────────────── # HELPER: Construir el payload para Shopify # ────────────────────────────────────────────────────────── def _build_shopify_product_payload(self, is_create=False): """ Construye el dict de producto para la API de Shopify. Maneja productos simples y productos con variantes (atributos). """ self.ensure_one() payload = { 'title': self.name, 'vendor': self.categ_id.name if self.categ_id else 'Hazard', 'status': self.shopify_status or 'draft', } # CANDADO PRO: Solo mandar descripción cuando se CREA el producto por primera vez. # Si es actualización (is_create=False), omitimos body_html para no borrar el trabajo en Shopify. if is_create: description = self.description_sale or self.description or '' payload['body_html'] = description # ── Variantes ───────────────────────────────────────── # Odoo puede tener variantes via product.attribute. # Si solo tiene 1 variante → payload simple con precio/SKU directo. # Si tiene múltiples → construimos el array de variantes. variants = self.product_variant_ids if len(variants) == 1: v = variants[0] v_payload = { 'sku': v.default_code or self.default_code or '', 'price': str(self.list_price), 'inventory_management': 'shopify', 'inventory_policy': 'deny', } if self.shopify_compare_at_price > 0: v_payload['compare_at_price'] = str(self.shopify_compare_at_price) if v.barcode: # Solo enviar barcode si Odoo tiene uno v_payload['barcode'] = v.barcode if v.shopify_variant_id: v_payload['id'] = v.shopify_variant_id payload['variants'] = [v_payload] else: # Múltiples variantes options = [] seen_attrs = [] for v in variants: for val in v.product_template_attribute_value_ids: attr_name = val.attribute_id.name if attr_name not in seen_attrs: seen_attrs.append(attr_name) # CANDADO PRO: No enviar 'options' en actualizaciones para evitar # error de Shopify con opciones vinculadas a metafields (Standard Taxonomy). if is_create: # Máximo 3 options en Shopify for i, attr_name in enumerate(seen_attrs[:3]): options.append({'name': attr_name}) payload['options'] = options shopify_variants = [] for v in variants: attr_vals = v.product_template_attribute_value_ids variant_data = { 'sku': v.default_code or '', 'price': str(v.lst_price), 'inventory_management': 'shopify', 'inventory_policy': 'deny', } if self.shopify_compare_at_price > 0: variant_data['compare_at_price'] = str(self.shopify_compare_at_price) if v.barcode: # Solo enviar barcode si Odoo tiene uno variant_data['barcode'] = v.barcode if v.shopify_variant_id: variant_data['id'] = v.shopify_variant_id # CANDADO PRO: Si es actualización y la variante ya existe, # omitimos asignar los valores de opción (option1, etc.) # para evitar sobrescribir metafields. if is_create or not v.shopify_variant_id: # Asignar option1, option2, option3 según posición for i, val in enumerate(attr_vals[:3]): variant_data[f'option{i+1}'] = val.name shopify_variants.append(variant_data) payload['variants'] = shopify_variants return payload # ────────────────────────────────────────────────────────── # HELPER: Subir imagen # ────────────────────────────────────────────────────────── def _push_image_to_shopify(self, api_base, shopify_product_id, headers): """ Sube la imagen principal del producto a Shopify. Evita duplicados consultando si ya existen imágenes asociadas. """ try: img_b64 = self.image_1920 if not img_b64: return # Evitar duplicados: consultar imágenes existentes en Shopify check_url = f"{api_base}/products/{shopify_product_id}/images.json" check_resp = requests.get(check_url, headers=headers, timeout=15) if check_resp.status_code == 200: existing_images = check_resp.json().get('images', []) if existing_images: _logger.info("[Shopify Push] El producto '%s' ya tiene imágenes en Shopify. Se omite subida duplicada.", self.name) return # Detectar si ya es bytes o string if isinstance(img_b64, bytes): img_str = img_b64.decode('utf-8') else: img_str = img_b64 url = f"{api_base}/products/{shopify_product_id}/images.json" payload = { 'image': { 'attachment': img_str, 'filename': f"{self.name.replace(' ', '_')}.jpg", 'alt': self.name, } } resp = requests.post(url, headers=headers, json=payload, timeout=30) if resp.status_code not in (200, 201): _logger.warning("[Shopify Push] Imagen no subida para '%s': HTTP %s — %s", self.name, resp.status_code, resp.text[:200]) else: _logger.info("[Shopify Push] Imagen subida para '%s'", self.name) except Exception as e: _logger.warning("[Shopify Push] Error subiendo imagen para '%s': %s", self.name, str(e)) # ────────────────────────────────────────────────────────── # HELPER: Guardar IDs de variantes devueltos por Shopify # ────────────────────────────────────────────────────────── def _sync_variant_ids_from_shopify(self, product_data): """ Después de crear/actualizar el producto en Shopify, guarda los IDs de variante e inventory_item_id en cada product.product de Odoo. El mapeo se hace por SKU (default_code) y como respaldo por Opciones. """ shopify_variants = product_data.get('variants', []) sku_map = {} option_map = {} for sv in shopify_variants: sku = (sv.get('sku') or '').strip() opt1 = (sv.get('option1') or '').strip().upper() opt2 = (sv.get('option2') or '').strip().upper() opt3 = (sv.get('option3') or '').strip().upper() opts_tuple = (opt1, opt2, opt3) data = { 'variant_id': str(sv.get('id', '')), 'inventory_item_id': str(sv.get('inventory_item_id', '')), } if sku: sku_map[sku] = data option_map[opts_tuple] = data for variant in self.product_variant_ids: sku = (variant.default_code or '').strip() matched_data = None if sku and sku in sku_map: matched_data = sku_map[sku] else: # Fallback to matching by options attr_vals = variant.product_template_attribute_value_ids opt1 = attr_vals[0].name.strip().upper() if len(attr_vals) > 0 else '' opt2 = attr_vals[1].name.strip().upper() if len(attr_vals) > 1 else '' opt3 = attr_vals[2].name.strip().upper() if len(attr_vals) > 2 else '' opts_tuple = (opt1, opt2, opt3) if opts_tuple in option_map: matched_data = option_map[opts_tuple] if matched_data: variant.write({ 'shopify_variant_id': matched_data['variant_id'], 'shopify_inventory_item_id': matched_data['inventory_item_id'], }) # ────────────────────────────────────────────────────────── # AUTO-SYNC: cuando se edita un producto ya publicado # ────────────────────────────────────────────────────────── SHOPIFY_SYNC_FIELDS = { 'name', 'description_sale', 'description', 'list_price', 'categ_id', # NOTA: image_1920 excluida intencionalmente para evitar push automático # al descargar imágenes desde Shopify (causaría sobreescribir barcodes vacíos). # Las imágenes se suben a Shopify solo cuando el usuario hace push manual. } def write(self, vals): res = super().write(vals) # Solo sincronizar si el producto ya está publicado en Shopify # y si cambió algún campo relevante changed = set(vals.keys()) & self.SHOPIFY_SYNC_FIELDS if changed: for record in self: if record.shopify_published and record.shopify_id: try: config = record._get_shopify_config() if config and config.auto_sync_products: record.action_push_to_shopify() except Exception as e: # No bloqueamos el guardado — solo logueamos _logger.error( "[Shopify Auto-sync] Error actualizando '%s' en Shopify: %s", record.name, str(e) ) return res # ────────────────────────────────────────────────────────── # ACCIÓN: Actualizar canales desde Shopify (solo lectura) # ────────────────────────────────────────────────────────── def action_refresh_shopify_channels(self): """ Botón manual: lee los canales de venta de Shopify y actualiza la tabla local sin hacer ningún cambio en Shopify. Útil para cargar el estado actual de canales de productos ya publicados. """ self.ensure_one() if not self.shopify_id: raise UserError("Este producto no está publicado en Shopify.") config = self._get_shopify_config() token = config._ensure_access_token() shop_domain = (config.shop_url or '').strip().lower() if '://' in shop_domain: shop_domain = shop_domain.split('://')[1].split('/')[0] headers = { 'X-Shopify-Access-Token': token, 'Content-Type': 'application/json', } api_base = f"https://{shop_domain}/admin/api/2024-01" self._read_shopify_channels(api_base, headers, self.shopify_id) return { 'type': 'ir.actions.client', 'tag': 'display_notification', 'params': { 'title': '✅ Canales actualizados', 'message': f"Se actualizaron los canales de venta para '{self.name}'.", 'type': 'success', 'sticky': False, } } # ────────────────────────────────────────────────────────── # HELPER: Leer canales de Shopify (solo lectura, sin mutations) # ────────────────────────────────────────────────────────── def _read_shopify_channels(self, api_base, headers, shopify_product_id): """ Lee los canales de venta de Shopify y el estado de publicación del producto. Crea/actualiza los registros locales SIN hacer ningún cambio en Shopify (solo lectura). """ self.ensure_one() graphql_url = f"{api_base}/graphql.json" config = self._get_shopify_config() # ── Paso 1: Obtener TODOS los canales de la tienda ────────── query_publications = """ query { publications(first: 30) { edges { node { id name } } } } """ try: resp = requests.post(graphql_url, headers=headers, json={'query': query_publications}, timeout=15) if resp.status_code != 200: _logger.warning("[Shopify Channels Read] Error consultando publicaciones: HTTP %s", resp.status_code) return data = resp.json() or {} if 'errors' in data: _logger.warning("[Shopify Channels Read] GraphQL error: %s", data.get('errors')) return edges = (data.get('data') or {}).get('publications', {}).get('edges', []) if not edges: _logger.warning("[Shopify Channels Read] No se encontraron canales.") return # ── Paso 2: Crear/actualizar registros shopify.publication ── ShopifyPub = self.env['shopify.publication'].sudo() all_pub_records = {} valid_pub_gids = [] for edge in edges: node = edge.get('node', {}) pub_gid = node.get('id') pub_name = (node.get('name') or '').strip() if not pub_gid: continue valid_pub_gids.append(pub_gid) pub_rec = ShopifyPub.search([ ('shopify_publication_id', '=', pub_gid), ('config_id', '=', config.id), ], limit=1) if not pub_rec: pub_rec = ShopifyPub.create({ 'name': pub_name, 'shopify_publication_id': pub_gid, 'config_id': config.id, }) elif pub_rec.name != pub_name: pub_rec.write({'name': pub_name}) all_pub_records[pub_gid] = pub_rec # Eliminar canales locales que ya no existen en Shopify obsolete_pubs = ShopifyPub.search([ ('config_id', '=', config.id), ('shopify_publication_id', 'not in', valid_pub_gids) ]) if obsolete_pubs: obsolete_pubs.unlink() # ── Paso 3: Leer estado actual de publicación del producto ── graphql_product_id = f"gid://shopify/Product/{shopify_product_id}" query_product_pubs = """ query getProductPublications($productId: ID!) { product(id: $productId) { resourcePublicationsV2(first: 30) { edges { node { isPublished publication { id name } } } } } } """ resp2 = requests.post( graphql_url, headers=headers, json={'query': query_product_pubs, 'variables': {'productId': graphql_product_id}}, timeout=15 ) currently_published_gids = set() if resp2.status_code == 200: data2 = resp2.json() or {} product_data = (data2.get('data') or {}).get('product') or {} pub_edges = (product_data.get('resourcePublicationsV2') or {}).get('edges', []) for pe in pub_edges: pn = pe.get('node', {}) if pn.get('isPublished'): pub_info = pn.get('publication') or {} pub_gid_val = pub_info.get('id') if pub_gid_val: currently_published_gids.add(pub_gid_val) # ── Paso 4: Crear/actualizar registros (SOLO LECTURA) ── ProdPub = self.env['shopify.product.publication'].sudo() for pub_gid, pub_rec in all_pub_records.items(): is_published = pub_gid in currently_published_gids existing = ProdPub.search([ ('product_tmpl_id', '=', self.id), ('publication_id', '=', pub_rec.id), ], limit=1) if existing: # Actualizar al estado real de Shopify if existing.is_published != is_published: existing.write({'is_published': is_published}) else: ProdPub.create({ 'product_tmpl_id': self.id, 'publication_id': pub_rec.id, 'is_published': is_published, }) _logger.info("[Shopify Channels Read] %s canales leídos para '%s'", len(all_pub_records), self.name) except Exception as e: _logger.warning("[Shopify Channels Read] Error: %s", str(e)) # ────────────────────────────────────────────────────────── # HELPER: Sincronizar canales de venta vía GraphQL # ────────────────────────────────────────────────────────── def _sync_shopify_channels(self, api_base, headers, shopify_product_id): """ Sincroniza la publicación del producto en TODOS los canales de Shopify. 1. Obtiene la lista completa de publicaciones (canales) de la tienda. 2. Crea/actualiza registros shopify.publication para cada canal. 3. Lee el estado actual de publicación del producto en cada canal. 4. Crea/actualiza registros shopify.product.publication. 5. Aplica cambios locales (publicar/despublicar) según los checkboxes de Odoo. """ self.ensure_one() graphql_url = f"{api_base}/graphql.json" config = self._get_shopify_config() # ── Paso 1: Obtener TODOS los canales de la tienda ────────── query_publications = """ query { publications(first: 30) { edges { node { id name } } } } """ try: resp = requests.post(graphql_url, headers=headers, json={'query': query_publications}, timeout=15) if resp.status_code != 200: _logger.warning("[Shopify Channels] Error al consultar publicaciones: HTTP %s", resp.status_code) return data = resp.json() or {} if 'errors' in data: _logger.warning( "[Shopify Channels] La consulta GraphQL devolvió un error (¿falta de permisos?): %s. " "Asegúrate de activar los permisos de 'Publications' en tu Custom App en Shopify.", data.get('errors') ) return data_content = data.get('data') or {} publications_content = data_content.get('publications') or {} edges = publications_content.get('edges', []) if not edges: _logger.warning("[Shopify Channels] No se encontraron canales de venta en Shopify.") return # ── Paso 2: Asegurar que existan registros shopify.publication ── ShopifyPub = self.env['shopify.publication'].sudo() all_pub_records = {} # {gid: shopify.publication record} valid_pub_gids = [] for edge in edges: node = edge.get('node', {}) pub_gid = node.get('id') pub_name = (node.get('name') or '').strip() if not pub_gid: continue valid_pub_gids.append(pub_gid) pub_rec = ShopifyPub.search([ ('shopify_publication_id', '=', pub_gid), ('config_id', '=', config.id), ], limit=1) if not pub_rec: pub_rec = ShopifyPub.create({ 'name': pub_name, 'shopify_publication_id': pub_gid, 'config_id': config.id, }) elif pub_rec.name != pub_name: pub_rec.write({'name': pub_name}) all_pub_records[pub_gid] = pub_rec # Eliminar canales locales que ya no existen en Shopify obsolete_pubs = ShopifyPub.search([ ('config_id', '=', config.id), ('shopify_publication_id', 'not in', valid_pub_gids) ]) if obsolete_pubs: obsolete_pubs.unlink() _logger.info("[Shopify Channels] %s canales de venta sincronizados.", len(all_pub_records)) # ── Paso 3: Leer estado actual de publicación del producto ── graphql_product_id = f"gid://shopify/Product/{shopify_product_id}" query_product_pubs = """ query getProductPublications($productId: ID!) { product(id: $productId) { resourcePublicationsV2(first: 30) { edges { node { isPublished publication { id name } } } } } } """ resp2 = requests.post( graphql_url, headers=headers, json={'query': query_product_pubs, 'variables': {'productId': graphql_product_id}}, timeout=15 ) # Set de GIDs donde el producto está actualmente publicado en Shopify currently_published_gids = set() if resp2.status_code == 200: data2 = resp2.json() or {} product_data = (data2.get('data') or {}).get('product') or {} pub_edges = (product_data.get('resourcePublicationsV2') or {}).get('edges', []) for pe in pub_edges: pn = pe.get('node', {}) if pn.get('isPublished'): pub_info = pn.get('publication') or {} pub_gid_val = pub_info.get('id') if pub_gid_val: currently_published_gids.add(pub_gid_val) else: _logger.warning("[Shopify Channels] No se pudo consultar el estado de publicación del producto: HTTP %s", resp2.status_code) # ── Paso 4: Crear/actualizar registros shopify.product.publication ── ProdPub = self.env['shopify.product.publication'].sudo() for pub_gid, pub_rec in all_pub_records.items(): is_currently_published = pub_gid in currently_published_gids existing = ProdPub.search([ ('product_tmpl_id', '=', self.id), ('publication_id', '=', pub_rec.id), ], limit=1) if existing: # Leer el valor local que el usuario podría haber cambiado local_desired = existing.is_published if local_desired != is_currently_published: # El usuario cambió algo en Odoo → aplicar cambio en Shopify self._set_shopify_channel_publication( graphql_url, headers, graphql_product_id, pub_gid, local_desired ) # Si no hay discrepancia, no hacer nada (el estado ya coincide) else: # Primera vez: crear registro con el estado real de Shopify ProdPub.create({ 'product_tmpl_id': self.id, 'publication_id': pub_rec.id, 'is_published': is_currently_published, }) except Exception as e: _logger.warning("[Shopify Channels] Error inesperado en la sincronización de canales: %s", str(e)) def _set_shopify_channel_publication(self, graphql_url, headers, graphql_product_id, pub_gid, should_publish): """ Publica o despublica un producto en un canal específico de Shopify vía GraphQL. """ mutation_name = "publishablePublish" if should_publish else "publishableUnpublish" mutation_query = f""" mutation {mutation_name}($id: ID!, $input: [PublicationInput!]!) {{ {mutation_name}(id: $id, input: $input) {{ userErrors {{ field message }} }} }} """ variables = { "id": graphql_product_id, "input": [{"publicationId": pub_gid}] } try: m_resp = requests.post(graphql_url, headers=headers, json={'query': mutation_query, 'variables': variables}, timeout=15) if m_resp.status_code == 200: errors = m_resp.json().get('data', {}).get(mutation_name, {}).get('userErrors', []) if errors: _logger.warning("[Shopify Channels] Error en mutation %s para %s: %s", mutation_name, pub_gid, errors) else: action_verb = "publicado" if should_publish else "despublicado" _logger.info("[Shopify Channels] Producto %s exitosamente en %s", action_verb, pub_gid) else: _logger.warning("[Shopify Channels] HTTP %s al ejecutar mutation %s para %s", m_resp.status_code, mutation_name, pub_gid) except Exception as e: _logger.warning("[Shopify Channels] Error al ejecutar mutation %s: %s", mutation_name, str(e)) # ────────────────────────────────────────────────────────── # ACCIÓN: Despublicar de Shopify # ────────────────────────────────────────────────────────── def action_unpublish_from_shopify(self): """ Cambia el status del producto a 'draft' en Shopify (no lo elimina, solo lo oculta de la tienda). """ self.ensure_one() if not self.shopify_id: raise UserError("Este producto no está publicado en Shopify.") config = self._get_shopify_config() token = config._ensure_access_token() shop_domain = (config.shop_url or '').strip().lower() if '://' in shop_domain: shop_domain = shop_domain.split('://')[1].split('/')[0] headers = { 'X-Shopify-Access-Token': token, 'Content-Type': 'application/json', } url = f"https://{shop_domain}/admin/api/2024-01/products/{self.shopify_id}.json" resp = requests.put(url, headers=headers, json={'product': {'id': self.shopify_id, 'status': 'draft'}}, timeout=15) if resp.status_code == 200: self.write({ 'shopify_published': False, 'shopify_sync_status': 'pending', 'shopify_status': 'draft', }) return { 'type': 'ir.actions.client', 'tag': 'display_notification', 'params': { 'title': 'Producto despublicado', 'message': f"'{self.name}' fue movido a borrador en Shopify.", 'type': 'warning', 'sticky': False, } } else: raise UserError(f"Error al despublicar en Shopify: HTTP {resp.status_code}") def action_generate_barcode(self): """ Botón manual para generar código de barras EAN-13 desde la ficha del Template """ for template in self: # Propagar a todas las variantes que no tengan barcode for variant in template.product_variant_ids: if not variant.barcode: new_barcode = variant._generate_ean13_for_record() if new_barcode: variant.write({'barcode': new_barcode}) _logger.info("Barcode generado manualmente (vía Template) para %s: %s", variant.name, new_barcode) class ProductProduct(models.Model): _inherit = 'product.product' shopify_variant_id = fields.Char( string='ID Variante Shopify', copy=False, index=True, readonly=True, ) shopify_inventory_item_id = fields.Char( string='ID Inventario Shopify', copy=False, index=True, readonly=True, ) @api.model def _compute_ean13_checksum(self, barcode_str): """ Calcula el dígito verificador para EAN-13 usando el Módulo 10 """ if len(barcode_str) != 12 or not barcode_str.isdigit(): return "0" sum_odd = sum(int(barcode_str[i]) for i in range(0, 12, 2)) sum_even = sum(int(barcode_str[i]) * 3 for i in range(1, 12, 2)) total_sum = sum_odd + sum_even remainder = total_sum % 10 checksum = 10 - remainder if remainder != 0 else 0 return str(checksum) def _generate_ean13_for_record(self): """ Obtiene el siguiente EAN-13 de la secuencia oficial """ # Pedimos el siguiente número a la secuencia de Odoo (ej: 750000000001) sequence_val = self.env['ir.sequence'].next_by_code('product.barcode.hazard') if not sequence_val: _logger.warning("No se pudo generar código de barras: Falta la secuencia 'product.barcode.hazard'") return False # Si el valor devuelto tiene menos de 12 dígitos por algún error de configuración, lo forzamos if len(sequence_val) < 12: sequence_val = sequence_val.zfill(12) elif len(sequence_val) > 12: sequence_val = sequence_val[:12] checksum = self._compute_ean13_checksum(sequence_val) return sequence_val + checksum @api.model_create_multi def create(self, vals_list): """ Intercepta la creación de productos nuevos para asignar Barcode automático si viene vacío """ for vals in vals_list: if not vals.get('barcode'): # Solo asignamos código si no trae uno manual y si estamos generando new_barcode = self._generate_ean13_for_record() if new_barcode: vals['barcode'] = new_barcode return super(ProductProduct, self).create(vals_list) def action_generate_barcode(self): """ Botón manual para generar código de barras desde la ficha del producto """ for record in self: if not record.barcode: new_barcode = record._generate_ean13_for_record() if new_barcode: record.write({'barcode': new_barcode}) _logger.info("Barcode generado manualmente para %s: %s", record.name, new_barcode)