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)