Files
hazard_odoo/scripts/product_template.py.backup_20260711_130410
T

1031 lines
47 KiB
Plaintext

# -*- 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)