1031 lines
47 KiB
Plaintext
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)
|