From 79a51c4099d0ecc486ca13a4138d99e754062100 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alberto=20S=C3=A1nchez?= Date: Wed, 15 Jul 2026 19:10:36 -0600 Subject: [PATCH] Update for production backup --- .agents/AGENTS.md | 2 + Addons/custom/hazard_reports/__init__.py | 1 + Addons/custom/hazard_reports/__manifest__.py | 15 + .../views/sale_order_report_views.xml | 72 ++ .../hazard_shopify/models/product_template.py | 96 +- .../hazard_shopify/views/product_views.xml | 3 + Manual_Operativo_Flujo_Custom_Actualizado.md | 120 ++ scripts/install_hazard_reports.py | 22 + ...product_template.py.backup_20260711_130410 | 1030 +++++++++++++++++ 9 files changed, 1353 insertions(+), 8 deletions(-) create mode 100644 Addons/custom/hazard_reports/__init__.py create mode 100644 Addons/custom/hazard_reports/__manifest__.py create mode 100644 Addons/custom/hazard_reports/views/sale_order_report_views.xml create mode 100644 Manual_Operativo_Flujo_Custom_Actualizado.md create mode 100644 scripts/install_hazard_reports.py create mode 100644 scripts/product_template.py.backup_20260711_130410 diff --git a/.agents/AGENTS.md b/.agents/AGENTS.md index 355745b..a88b400 100644 --- a/.agents/AGENTS.md +++ b/.agents/AGENTS.md @@ -355,3 +355,5 @@ Ejemplo: `HRS-POL-CLUB-M-BLK-L` = Polo Club, Hombre, Negro, Talla L - NUNCA crees scripts temporales de Python (o bash) directamente en la carpeta raíz del proyecto. - Si necesitas crear un script (`.py`, `.sh`, etc.) para una tarea, colócalo SIEMPRE dentro del directorio `scripts/`. Crea este directorio si no existe. +## Ejecución y Cambios de Código +- NUNCA apliques cambios en el código ni ejecutes comandos que modifiquen el entorno sin antes consultar y recibir la autorización explícita ("Adelante", "Ok", "Dale", etc.) del usuario. Debes plantear la solución primero y esperar aprobación. diff --git a/Addons/custom/hazard_reports/__init__.py b/Addons/custom/hazard_reports/__init__.py new file mode 100644 index 0000000..40a96af --- /dev/null +++ b/Addons/custom/hazard_reports/__init__.py @@ -0,0 +1 @@ +# -*- coding: utf-8 -*- diff --git a/Addons/custom/hazard_reports/__manifest__.py b/Addons/custom/hazard_reports/__manifest__.py new file mode 100644 index 0000000..ae6452a --- /dev/null +++ b/Addons/custom/hazard_reports/__manifest__.py @@ -0,0 +1,15 @@ +{ + 'name': 'Hazard Reports & Dashboards', + 'version': '1.0', + 'summary': 'KPIs and Pivot reports for Sales and Operations', + 'description': 'Adds custom Dashboards and Pivot views for sale.order to analyze Retail vs B2B performance.', + 'category': 'Sales/Sales', + 'author': 'TESS', + 'depends': ['sale', 'sale_management'], + 'data': [ + 'views/sale_order_report_views.xml', + ], + 'installable': True, + 'application': False, + 'license': 'LGPL-3', +} diff --git a/Addons/custom/hazard_reports/views/sale_order_report_views.xml b/Addons/custom/hazard_reports/views/sale_order_report_views.xml new file mode 100644 index 0000000..ddf4b4b --- /dev/null +++ b/Addons/custom/hazard_reports/views/sale_order_report_views.xml @@ -0,0 +1,72 @@ + + + + + sale.order.kpi.pivot + sale.order + + + + + + + + + + + + + sale.order.kpi.graph + sale.order + + + + + + + + + + + sale.order.kpi.search + sale.order + + + + + + + + + + + + + Análisis y KPIs (B2B / Retail) + sale.order + pivot,graph,tree,form + [('state', 'in', ['sale', 'done'])] + + + + + + + pivot + + + + + + graph + + + + + + + diff --git a/Addons/custom/hazard_shopify/models/product_template.py b/Addons/custom/hazard_shopify/models/product_template.py index 4a7a1f1..c38bf30 100644 --- a/Addons/custom/hazard_shopify/models/product_template.py +++ b/Addons/custom/hazard_shopify/models/product_template.py @@ -23,6 +23,11 @@ class ProductTemplate(models.Model): 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( @@ -146,29 +151,84 @@ class ProductTemplate(models.Model): } 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. + # ── Consulta preventiva a Shopify (si el producto ya existe) ────────────── + # Se hace UNA sola consulta GET al inicio para: + # A) Recuperar barcodes de Shopify (evita borrarlos si Odoo los tiene vacíos). + # B) Auto-enlazar IDs de variante por SKU ANTES de construir el payload. + # Esto es CRÍTICO: si Odoo no tiene el shopify_variant_id de una variante, + # la trataría como nueva y mandaría POST a crearla. Shopify rechaza eso + # si la opción está vinculada a un metacampo de categoría. 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', {}) + + # A) Respaldo de barcodes 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]}) + + # B) Auto-enlazado preventivo de IDs de variante por SKU + # Construimos dos estructuras desde la respuesta de Shopify: + # sku_to_ids: SKU → {variant_id, inventory_item_id} + # existing_variant_ids: conjunto de IDs activos en Shopify + sku_to_ids = {} + existing_variant_ids = set() + for var in prod_data.get('variants', []): + sku = (var.get('sku') or '').strip() + v_id_str = str(var.get('id', '')) + existing_variant_ids.add(v_id_str) + if sku: + sku_to_ids[sku] = { + 'variant_id': v_id_str, + 'inventory_item_id': str(var.get('inventory_item_id', '')), + } + + for v in self.product_variant_ids: + sku = (v.default_code or '').strip() + + # C) Limpiar IDs obsoletos: si Odoo tiene un variant_id que ya no + # existe en Shopify (la variante fue borrada allá), limpiamos el + # campo para que Odoo la trate como nueva y la intente crear. + if v.shopify_variant_id and v.shopify_variant_id not in existing_variant_ids: + _logger.info( + "[Shopify Push] Variante obsoleta detectada: SKU '%s' tenía variant_id=%s " + "que ya no existe en Shopify. Se limpia para intentar recrearla.", + sku, v.shopify_variant_id + ) + v.sudo().write({ + 'shopify_variant_id': False, + 'shopify_inventory_item_id': False, + }) + + # B) Para variantes sin ID de Shopify, buscamos enlace por SKU + if not v.shopify_variant_id and sku: + if sku in sku_to_ids: + ids_data = sku_to_ids[sku] + write_vals = {} + if ids_data.get('variant_id'): + write_vals['shopify_variant_id'] = ids_data['variant_id'] + if ids_data.get('inventory_item_id'): + write_vals['shopify_inventory_item_id'] = ids_data['inventory_item_id'] + if write_vals: + v.sudo().write(write_vals) + _logger.info( + "[Shopify Push] Auto-enlazado preventivo: SKU '%s' → variant_id=%s", + sku, ids_data['variant_id'] + ) + except Exception as e_bc: - _logger.warning("[Shopify Push] No se pudieron resguardar los barcodes de Shopify: %s", str(e_bc)) + _logger.warning("[Shopify Push] Error en consulta preventiva a Shopify: %s", str(e_bc)) # ── Construir payload ───────────────────────────────── is_create = not bool(self.shopify_id) @@ -196,7 +256,7 @@ class ProductTemplate(models.Model): _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 + # Si no tiene ID, es una variante realmente nueva → intentar crearla 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): @@ -205,7 +265,21 @@ class ProductTemplate(models.Model): 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}") + + # Detectar el error específico de metacampos vinculados y dar un tip claro al usuario + error_str = str(actual_error) + if 'linked to a metafield' in error_str or 'Cannot set name' in error_str: + tip_msg = ( + f"No se pudo crear la variante (SKU: {var_data.get('sku')}) porque " + f"la opción de talla/color en Shopify está vinculada a un Metacampo.\n" + f"💡 TIP: En Shopify → Producto → Variantes → clic en 'Talla' (o 'Color') " + f"→ selecciona 'Desconectar'. Luego intenta de nuevo desde Odoo." + ) + warning_messages.append(tip_msg) + else: + warning_messages.append( + f"No se pudo crear la variante (SKU: {var_data.get('sku')}). Shopify respondió: {actual_error}" + ) else: has_new_variants = True @@ -339,6 +413,9 @@ class ProductTemplate(models.Model): '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: @@ -372,6 +449,9 @@ class ProductTemplate(models.Model): '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 diff --git a/Addons/custom/hazard_shopify/views/product_views.xml b/Addons/custom/hazard_shopify/views/product_views.xml index bad0c1d..5f7d268 100644 --- a/Addons/custom/hazard_shopify/views/product_views.xml +++ b/Addons/custom/hazard_shopify/views/product_views.xml @@ -78,6 +78,9 @@ string="ID Producto Shopify" readonly="1" attrs="{'invisible': [('shopify_id', '=', False)]}"/> + diff --git a/Manual_Operativo_Flujo_Custom_Actualizado.md b/Manual_Operativo_Flujo_Custom_Actualizado.md new file mode 100644 index 0000000..a7b0543 --- /dev/null +++ b/Manual_Operativo_Flujo_Custom_Actualizado.md @@ -0,0 +1,120 @@ +# Manual Operativo Definitivo: Flujo Custom y Maquila (B2B) + +Este manual detalla paso a paso cómo debe operar el personal de la empresa (Ventas, Compras y Almacén) para vender productos a la medida maquilados por terceros, garantizando que **nada de esto interfiera con Shopify**. + +--- + +## FASE 0: El Blindaje contra Shopify (Para tu tranquilidad) +**¿Cómo evitamos que se mezcle con Shopify?** +Implementamos un "candado" a nivel de código en tu Odoo. Creamos dos categorías de inventario intocables por Shopify: +1. `Insumos / Materia Prima` +2. `Producto Final Custom` + +Si alguien por error intenta presionar el botón "Publicar en Shopify" en cualquier producto de estas categorías, **Odoo lanzará un error bloqueando la acción**. La plataforma de Retail (Shopify) está tecnológicamente aislada de este flujo B2B. + +--- + +## FASE 1: Creación de Insumos y Productos (Responsable: Compras / Almacén) +Antes de vender, hay que decirle a Odoo qué materiales tenemos, cómo se enviarán a la fábrica y qué vamos a vender. + +### 1.1 Crear el Insumo (Ej. La Tela o Botones) +1. Ir al módulo de **Inventario > Productos > Crear**. +2. **Nombre:** `Tela Azul Premium` o `Botones` +3. **Tipo de Producto:** `Almacenable` (para poder contar las existencias). +4. **Categoría de Producto:** `Insumos / Materia Prima` *(¡CRÍTICO para el blindaje!)*. +5. **Configuración de Rutas y Proveedores (¡Muy Importante!):** + Aquí debes elegir cómo llegará este insumo a la fábrica maquiladora. Tienes dos escenarios: + + * **Escenario A: El insumo sale de nuestro almacén (Flujo Normal)** + * Ve a la pestaña **Inventario** y en la sección de Rutas marca: **"Reabastecer al subcontratista al ordenar"**. + * *(Esto le dirá a Odoo que nos pida sacar la mercancía de nuestra bodega).* + + * **Escenario B: El proveedor de tela la envía directo a la fábrica (Triangulación / Dropshipping)** + * Ve a la pestaña **Inventario** y en Rutas marca: **"Enviar al subcontratista al ordenar"** (Dropship Subcontractor). + * Ve a la pestaña **Compras** y agrega al proveedor que te vende esta tela. + * *(Esto le dirá a Odoo que genere una compra automática a ese proveedor enviando la tela directo a la maquila).* + +6. Guardar. + +### 1.2 Crear el Producto Final (Ej. La Camisa) +1. Ir a **Inventario > Productos > Crear**. +2. **Nombre:** `Camisa Custom Club de Golf` +3. **Tipo de Producto:** `Almacenable`. +4. **Categoría de Producto:** `Producto Final Custom` *(¡CRÍTICO!)*. +5. Ir a la pestaña **Compra**: Haz clic en *Agregar una línea* y pon el nombre de la fábrica que la va a maquilar (ej. *Fábrica China*) y el costo que te cobran por mano de obra. +6. Guardar. + +### 1.3 Unir el Insumo y la Camisa (La Lista de Materiales / BoM) +Odoo necesita saber qué se usa para fabricar la camisa. +1. Dentro de la pantalla de la `Camisa Custom`, haz clic en el botón superior que dice **Lista de Materiales (BoM)** y dale a **Crear**. +2. **Tipo de BoM:** Selecciona **Subcontratación** *(Subcontracting)*. +3. **Subcontratista:** Selecciona a la *Fábrica China*. +4. **Componentes:** Abajo, agrega la `Tela Azul Premium` y los `Botones`, indicando la cantidad necesaria para 1 sola camisa. +5. Guardar. + +--- + +## FASE 2: La Venta (Responsable: Ventas B2B) +El cliente corporativo acepta trabajar con nosotros. + +> [!TIP] +> **Precios Tabulados (Descuentos por Volumen)** +> Si el precio al cliente cambia a partir de cierta cantidad (ej. si compran más de 50 camisas el precio baja), Ventas puede configurar esto automáticamente: +> 1. Ir a **Ventas > Productos > Listas de Precios**. +> 2. Crear una regla para la `Camisa Custom`, definiendo la "Cantidad Mínima" (ej. 50) y el nuevo precio. Al hacer la cotización, Odoo ajustará el precio solo si se supera esa cantidad. + +1. Ir al módulo de **Ventas > Crear Cotización**. +2. **Cliente:** `Club de Golf` +3. Agregar el producto `Camisa Custom Club de Golf` y poner la cantidad (ej. 50 camisas). +4. Hacer clic en **Confirmar**. +> **Resultado:** Se genera la orden de salida del almacén (`ALMA/OUT`), pero se queda en estado **"En Espera"** porque aún no existen las camisas físicas. + +--- + +## FASE 3: Mandar a Maquilar (Responsable: Compras) +Ventas ya vendió, ahora Compras debe pedirle a la fábrica que trabaje. +1. Ir al módulo de **Compras > Crear**. +2. **Proveedor:** Seleccionar `Fábrica China`. +3. **Producto:** Agregar `Camisa Custom Club de Golf` (50 camisas). +4. Hacer clic en **Confirmar Orden**. +> **Nota sobre Anticipos:** Si la fábrica pide un adelanto, en esta misma pantalla de Compras puedes darle a *Crear Factura* (Factura de Anticipo) para que Finanzas pague. + +--- + +## FASE 4: Abastecer a la Fábrica de Insumos +Al confirmar el Paso 3, Odoo automáticamente prepara los materiales para la fábrica, dependiendo del escenario que configuramos en la Fase 1: + +* **Si usamos el Escenario A (Salió de nuestro almacén):** + 1. El responsable va a **Inventario**. Aparecerá una tarjeta llamada **Resuministro de Subcontratista**. + 2. Al entrar, Almacén verá una orden pidiendo enviar los botones y la tela. + 3. El bodeguero se los da a paquetería y en Odoo le da clic a **Validar**. + +* **Si usamos el Escenario B (Triangulación directa del proveedor a la fábrica):** + 1. Odoo creó una **segunda Orden de Compra en estado Borrador** mágicamente. + 2. El de Compras va al módulo de **Compras** y verá esta nueva orden a nombre del proveedor de telas. Las cantidades estarán calculadas automáticamente (ej. 50 camisas x 1.5m = 75m) y la *Dirección de Entrega* será la Fábrica China. + 3. El de Compras solo entra y le da clic a **Confirmar**. ¡Listo! + +--- + +## FASE 5: Recepción de Camisas Terminadas (Responsable: Almacén) +Llegan las camisas listas. +1. Ir a **Inventario > Recepciones (`ALMA/IN`)**. +2. Abrir la recepción vinculada a la orden de la Fábrica China. +3. Contar las camisas, poner la cantidad recibida y dar clic en **Validar**. +> **El Costeo Automático:** Odoo acaba de consumir la tela y los botones, y le sumó el costo de la mano de obra. Todo está asignado al costo de la camisa terminada. + +> [!NOTE] +> **Añadir Costos Extras (Costos en Destino / Landed Costs)** +> ¿Qué pasa si pagaste un flete de última hora, aduanas, o un planchado externo y quieres que eso sume al valor de las camisas? +> 1. Ve a **Inventario > Operaciones > Costos en Destino**. +> 2. Crea uno nuevo, ingresa el monto de la factura del flete y selecciona el movimiento de Recepción de las camisas (el que acabas de validar). +> 3. Al confirmar, Odoo tomará ese gasto extra y lo repartirá equitativamente entre las 50 camisas, aumentando su valor contable. + +--- + +## FASE 6: Entrega al Cliente Final (Responsable: Almacén / Finanzas) +Como ya tenemos camisas, la orden de Ventas se desbloquea. +1. Ir a **Inventario > Órdenes de Entrega (`ALMA/OUT`)**. +2. La orden del *Club de Golf* (Fase 2) que estaba "En Espera", ahora dirá **"Listo"**. +3. El empacador saca las camisas para el cliente y le da clic a **Validar**. +4. Finalmente, Finanzas entra a la orden de Venta original y da clic en **Crear Factura** para el cliente. ¡Fin del proceso! diff --git a/scripts/install_hazard_reports.py b/scripts/install_hazard_reports.py new file mode 100644 index 0000000..0c526bb --- /dev/null +++ b/scripts/install_hazard_reports.py @@ -0,0 +1,22 @@ +#!/usr/bin/env python3 +import xmlrpc.client + +ODOO_URL = 'http://localhost:16001' +ODOO_DB = 'hazard_new' +ODOO_USER = 'admin' +ODOO_PASS = 'admin' + +common = xmlrpc.client.ServerProxy(f'{ODOO_URL}/xmlrpc/2/common') +uid = common.authenticate(ODOO_DB, ODOO_USER, ODOO_PASS, {}) +models = xmlrpc.client.ServerProxy(f'{ODOO_URL}/xmlrpc/2/object') + +print("Actualizando lista de modulos...") +models.execute_kw(ODOO_DB, uid, ODOO_PASS, 'ir.module.module', 'update_list', []) + +module_ids = models.execute_kw(ODOO_DB, uid, ODOO_PASS, 'ir.module.module', 'search', [[('name', '=', 'hazard_reports')]]) +if module_ids: + print(f"Instalando modulo hazard_reports (ID: {module_ids[0]})...") + models.execute_kw(ODOO_DB, uid, ODOO_PASS, 'ir.module.module', 'button_immediate_install', [module_ids]) + print("Instalacion exitosa.") +else: + print("Modulo no encontrado.") diff --git a/scripts/product_template.py.backup_20260711_130410 b/scripts/product_template.py.backup_20260711_130410 new file mode 100644 index 0000000..d6986d7 --- /dev/null +++ b/scripts/product_template.py.backup_20260711_130410 @@ -0,0 +1,1030 @@ +# -*- 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)