🎉 Initial commit - Hazard Odoo 16 project

- Custom modules: hazard_shopify, hazard_inventory_report, hazard_website
- Third-party modules: bi_sql_editor, query_deluxe, sql_request_abstract, l10n_mx_sat_sync_itadmin_ee
- Docker setup: Odoo 16 + PostgreSQL 17
- Utility scripts: backup_hazard.sh, update_module.py
- Agent configuration: .agents/AGENTS.md
- Security: Enterprise modules, credentials, backups and production data excluded via .gitignore
This commit is contained in:
2026-06-30 06:12:35 -06:00
commit 28d9e56f47
350 changed files with 99832 additions and 0 deletions
+2
View File
@@ -0,0 +1,2 @@
from . import models
from . import wizard
@@ -0,0 +1,30 @@
{
'name': 'Hazard Shopify Connector',
'version': '1.0',
'summary': 'Conector básico de lectura para Shopify - Proyecto Hazard',
'category': 'Sales',
'author': 'Antigravity / Hazard',
'depends': ['sale', 'stock', 'product', 'sale_stock', 'delivery'],
'data': [
'security/hazard_security.xml',
'security/ir.model.access.csv',
'data/ir_sequence_data.xml',
'data/shopify_crons.xml',
'data/delivery_carrier_data.xml',
'views/shopify_config_views.xml',
'views/sale_order_shopify_views.xml',
'views/product_views.xml',
'wizard/shopify_refund_wizard_views.xml',
'data/label_paperformat.xml',
'views/product_label_templates.xml',
],
'assets': {
'web.assets_backend': [
'hazard_shopify/static/src/css/hazard_style.css',
],
},
'installable': True,
'application': True,
'license': 'LGPL-3',
}
@@ -0,0 +1,159 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Script de limpieza completa de datos de prueba en Odoo / Hazard.
Elimina en el orden correcto respetando FK constraints:
1. Facturas borrador vinculadas a pedidos Shopify
2. Entregas/Pickings vinculadas a pedidos Shopify
3. Pedidos de venta de Shopify
4. Productos importados planos (1 por variante)
5. Contactos falsos "Cliente Shopify #XXXX"
Ejecutar con:
docker exec -it odoo-16-hazard-new odoo shell -d hazard_new --no-http < /mnt/extra-addons/cleanup_shopify_data.py
"""
import logging
_logger = logging.getLogger(__name__)
print("=" * 60)
print("LIMPIEZA DE DATOS DE PRUEBA — HAZARD")
print("=" * 60)
# ─────────────────────────────────────────────────────────────
# PASO 1: Facturas borrador vinculadas a pedidos Shopify
# ─────────────────────────────────────────────────────────────
print("\n[1/5] Buscando facturas borrador de pedidos Shopify...")
shopify_orders = env['sale.order'].search([('shopify_order_id', '!=', False)])
print(f"{len(shopify_orders)} pedidos de Shopify encontrados")
invoices = shopify_orders.mapped('invoice_ids').filtered(lambda i: i.state in ('draft', 'cancel'))
print(f"{len(invoices)} facturas borrador/canceladas a eliminar")
cancelled = 0
for inv in invoices:
try:
with env.cr.savepoint():
if inv.state == 'draft':
inv.button_cancel()
inv.unlink()
cancelled += 1
except Exception as e:
print(f" ⚠️ No se pudo eliminar factura {inv.name}: {e}")
env.cr.commit()
print(f"{cancelled} facturas eliminadas")
# ─────────────────────────────────────────────────────────────
# PASO 2: Pickings / Entregas vinculadas a pedidos Shopify
# ─────────────────────────────────────────────────────────────
print("\n[2/5] Eliminando entregas/pickings de pedidos Shopify...")
pickings = shopify_orders.mapped('picking_ids')
print(f"{len(pickings)} pickings a eliminar")
deleted_pickings = 0
for picking in pickings:
try:
with env.cr.savepoint():
if picking.state not in ('done', 'cancel'):
picking.action_cancel()
if picking.state == 'cancel':
picking.unlink()
deleted_pickings += 1
elif picking.state == 'done':
# Picking ya procesado — no se puede borrar, solo informamos
print(f" ⚠️ Picking {picking.name} ya validado (done), no se puede eliminar")
except Exception as e:
print(f" ⚠️ Error en picking {picking.name}: {e}")
env.cr.commit()
print(f"{deleted_pickings} pickings eliminados")
# ─────────────────────────────────────────────────────────────
# PASO 3: Pedidos de venta de Shopify
# ─────────────────────────────────────────────────────────────
print("\n[3/5] Eliminando pedidos de venta de Shopify...")
# Recargar por si cambiaron las relaciones
shopify_orders = env['sale.order'].search([('shopify_order_id', '!=', False)])
print(f"{len(shopify_orders)} pedidos a eliminar")
deleted_orders = 0
for order in shopify_orders:
try:
with env.cr.savepoint():
if order.state not in ('draft', 'cancel'):
try:
order.action_cancel()
except Exception as cancel_e:
print(f" ⚠️ Aviso: No se pudo cancelar el pedido {order.name}: {cancel_e}")
# Solo intentamos borrar si no está 'done' o si se pudo cancelar
if order.state in ('draft', 'cancel'):
order.unlink()
deleted_orders += 1
else:
print(f" ⚠️ Pedido {order.name} en estado {order.state}, no se puede eliminar")
except Exception as e:
print(f" ⚠️ No se pudo eliminar pedido {order.name}: {e}")
env.cr.commit()
print(f"{deleted_orders} pedidos eliminados")
# ─────────────────────────────────────────────────────────────
# PASO 4: Productos importados planos de Shopify
# ─────────────────────────────────────────────────────────────
print("\n[4/5] Eliminando productos planos importados de Shopify...")
templates_to_delete = env['product.template'].search([])
# Excluir productos del sistema que no deben borrarse
system_skus = ['ENVIO', 'SHOPIFY-GENERIC']
templates_to_delete = templates_to_delete.filtered(
lambda t: t.default_code not in system_skus
)
print(f"{len(templates_to_delete)} product templates a eliminar")
deleted_products = 0
errors_products = 0
for tmpl in templates_to_delete:
try:
with env.cr.savepoint():
tmpl.unlink()
deleted_products += 1
except Exception as e:
# Si tiene dependencias (stock moves done, etc.) archivamos en lugar de borrar
try:
with env.cr.savepoint():
tmpl.write({'active': False})
print(f" ️ Producto '{tmpl.name[:40]}' archivado (no se pudo eliminar)")
except Exception as e2:
print(f" ⚠️ Error al archivar '{tmpl.name[:40]}'")
errors_products += 1
env.cr.commit()
print(f"{deleted_products} productos eliminados, {errors_products} errores")
# ─────────────────────────────────────────────────────────────
# PASO 5: Contactos (OMITIDO A PETICIÓN DEL USUARIO)
# ─────────────────────────────────────────────────────────────
print("\n[5/5] Eliminando contactos... (OMITIDO)")
# ─────────────────────────────────────────────────────────────
# RESUMEN FINAL
# ─────────────────────────────────────────────────────────────
print("\n" + "=" * 60)
print("LIMPIEZA COMPLETADA")
print("=" * 60)
# Verificación
remaining_orders = env['sale.order'].search_count([('shopify_order_id', '!=', False)])
remaining_products = env['product.template'].search_count([])
remaining_contacts = env['res.partner'].search_count([('name', 'ilike', 'Cliente Shopify #')])
print(f"✅ Pedidos Shopify restantes: {remaining_orders}")
print(f"✅ Productos restantes en Odoo: {remaining_products}")
print(f"✅ Contactos 'Shopify #' restantes: {remaining_contacts}")
print("\nListo para reimportar productos con la estructura correcta.")
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<odoo noupdate="1">
<!-- Método de envío: Mensajería Local (Taxi/Mensajero de confianza) -->
<record id="delivery_carrier_mensajeria_local" model="delivery.carrier">
<field name="name">Mensajería Local (Taxi/Mensajero)</field>
<field name="delivery_type">fixed</field>
<field name="fixed_price">0.0</field>
<field name="product_id" ref="delivery.product_product_delivery"/>
</record>
</odoo>
@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data noupdate="1">
<!-- Sequence for Barcode Generation (EAN-13) -->
<record id="seq_product_barcode_hazard" model="ir.sequence">
<field name="name">Secuencia de Códigos de Barras Hazard (EAN-13)</field>
<field name="code">product.barcode.hazard</field>
<field name="prefix">200001</field> <!-- Standard GS1 + Company format prefix -->
<field name="padding">6</field> <!-- E.g. 200001 000001 (12 digits, then + checksum) -->
<field name="company_id" eval="False"/>
</record>
</data>
</odoo>
@@ -0,0 +1,19 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data>
<record id="paperformat_label_thermal2x1" model="report.paperformat">
<field name="name">Etiqueta Termica 2x1 (51x25mm)</field>
<field name="default" eval="False" />
<field name="format">custom</field>
<field name="page_width">51</field>
<field name="page_height">25</field>
<field name="orientation">Portrait</field>
<field name="margin_top">0</field>
<field name="margin_bottom">0</field>
<field name="margin_left">0</field>
<field name="margin_right">0</field>
<field name="disable_shrinking" eval="True"/>
<field name="dpi">96</field>
</record>
</data>
</odoo>
@@ -0,0 +1,96 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data noupdate="1">
<!-- ================================================================
CRON 1: Importación Automática de Pedidos (Shopify → Odoo)
Activo solo cuando 'auto_import_orders' = True en la config.
================================================================ -->
<record id="ir_cron_shopify_import_orders" model="ir.cron">
<field name="name">Shopify: Importar Pedidos Nuevos</field>
<field name="model_id" ref="model_shopify_config"/>
<field name="state">code</field>
<field name="code">model.cron_import_orders()</field>
<field name="interval_number">5</field>
<field name="interval_type">minutes</field>
<field name="numbercall">-1</field>
<field name="active">True</field>
<field name="user_id" ref="base.user_root"/>
<field name="priority">5</field>
</record>
<!-- ================================================================
CRON 2: Sincronización de Inventario (Odoo ➔ Shopify)
Odoo manda. Odoo es la Fuente de Verdad.
Por defecto INACTIVO: se activa cuando estén listos.
================================================================ -->
<record id="ir_cron_shopify_sync_inventory" model="ir.cron">
<field name="name">Shopify: Sincronizar Inventario (Odoo ➔ Shopify)</field>
<field name="model_id" ref="model_shopify_config"/>
<field name="state">code</field>
<field name="code">model.cron_sync_inventory_to_shopify()</field>
<field name="interval_number">12</field>
<field name="interval_type">hours</field>
<field name="numbercall">-1</field>
<field name="active">False</field>
<field name="user_id" ref="base.user_root"/>
<field name="priority">10</field>
</record>
<!-- ================================================================
CRON 3: Renovación Preventiva de Tokens (Shopify OAuth)
Shopify emite tokens con duración de 24h. Este cron los renueva
automáticamente cada 20 horas para evitar interrupciones de servicio
sin intervención manual del cliente.
================================================================ -->
<record id="ir_cron_shopify_refresh_tokens" model="ir.cron">
<field name="name">Shopify: Renovar Tokens de Acceso (Automático)</field>
<field name="model_id" ref="model_shopify_config"/>
<field name="state">code</field>
<field name="code">model.cron_refresh_tokens()</field>
<field name="interval_number">20</field>
<field name="interval_type">hours</field>
<field name="numbercall">-1</field>
<field name="active">True</field>
<field name="user_id" ref="base.user_root"/>
<field name="priority">1</field>
</record>
<!-- ================================================================
CRON 4: Descarga de Imágenes por Lotes (Shopify → Odoo)
Procesa 15 productos sin imagen por ejecución.
Se ACTIVA desde el botón y se auto-DESACTIVA al terminar.
================================================================ -->
<record id="ir_cron_shopify_download_images" model="ir.cron">
<field name="name">Shopify: Descargar Imágenes de Productos</field>
<field name="model_id" ref="model_shopify_config"/>
<field name="state">code</field>
<field name="code">model._cron_download_images_batch()</field>
<field name="interval_number">1</field>
<field name="interval_type">minutes</field>
<field name="numbercall">-1</field>
<field name="active">False</field>
<field name="user_id" ref="base.user_root"/>
<field name="priority">5</field>
</record>
<!-- ================================================================
CRON 5: Sincronización Permanente y Automática de Nuevas Imágenes
Busca productos sin foto en Odoo, descarga la portada y NUNCA se desactiva.
Se ejecuta cada 2 horas de forma 100% transparente para el usuario.
================================================================ -->
<record id="ir_cron_shopify_auto_image_sync" model="ir.cron">
<field name="name">Shopify: Auto-Descargar Imágenes Nuevas (Permanente)</field>
<field name="model_id" ref="model_shopify_config"/>
<field name="state">code</field>
<field name="code">model.cron_auto_download_images()</field>
<field name="interval_number">2</field>
<field name="interval_type">hours</field>
<field name="numbercall">-1</field>
<field name="active">True</field>
<field name="user_id" ref="base.user_root"/>
<field name="priority">8</field>
</record>
</data>
</odoo>
@@ -0,0 +1,11 @@
from . import sku_simulation_line
from . import sku_logic
from . import shopify_publication
from . import shopify_product_publication
from . import product_template
from . import sale_order
from . import shopify_location_wizard
from . import shopify_config
from . import res_users
from . import shopify_cost_line
from . import product_label_layout
@@ -0,0 +1,40 @@
# -*- coding: utf-8 -*-
from odoo import api, fields, models
from odoo.addons.product.report.product_label_report import _prepare_data
class ProductLabelLayout(models.TransientModel):
_inherit = 'product.label.layout'
print_format = fields.Selection(selection_add=[
('thermal2x1', 'Etiqueta Térmica 2x1 (51x25mm)')
], ondelete={'thermal2x1': 'set default'})
@api.depends('print_format')
def _compute_dimensions(self):
for wizard in self:
if wizard.print_format == 'thermal2x1':
wizard.columns, wizard.rows = 1, 1
else:
# Re-implement super logic to avoid ValueError on 'thermal2x1'
if wizard.print_format and 'x' in wizard.print_format:
columns, rows = wizard.print_format.split('x')[:2]
wizard.columns = int(columns)
wizard.rows = int(rows)
else:
wizard.columns, wizard.rows = 1, 1
def _prepare_report_data(self):
xml_id, data = super(ProductLabelLayout, self)._prepare_report_data()
if self.print_format == 'thermal2x1':
xml_id = 'hazard_shopify.report_product_template_label_thermal2x1'
return xml_id, data
class ReportProductTemplateLabelThermal2x1(models.AbstractModel):
_name = 'report.hazard_shopify.report_producttemplatelabel_thermal2x1'
_description = 'Product Label Report (Thermal 2x1)'
def _get_report_values(self, docids, data):
return _prepare_data(self.env, data)
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,34 @@
# -*- coding: utf-8 -*-
from odoo import models, api
class ResUsers(models.Model):
_inherit = 'res.users'
@api.model
def _search(self, args, offset=0, limit=None, order=None, count=False, access_rights_uid=None):
# 1. Comprobamos si el contexto actual es de superusuario o del propio sistema (uid=1)
# En estos casos no aplicamos ningún filtro para evitar romper la lógica interna de Odoo
if self.env.su or self.env.uid == 1:
return super(ResUsers, self)._search(args, offset=offset, limit=limit, order=order, count=count, access_rights_uid=access_rights_uid)
# 2. Obtenemos el usuario actual
current_user = self.env.user
# 3. Determinamos si el usuario actual es de soporte
# Se considera de soporte si su login es 'admin' o si su login termina en @tesscorp.com.mx
is_support_user = False
if current_user:
login = current_user.login or ''
is_support_user = login == 'admin' or login.endswith('@tesscorp.com.mx')
# 4. Si el usuario actual NO es de soporte, ocultamos los logins de soporte
if not is_support_user:
# Clonamos args para no mutar el argumento original de forma destructiva
args = list(args) if args else []
# Ocultamos el login 'admin' y cualquier cuenta de soporte con dominio @tesscorp.com.mx
args.extend([
('login', '!=', 'admin'),
('login', 'not ilike', '@tesscorp.com.mx')
])
return super(ResUsers, self)._search(args, offset=offset, limit=limit, order=order, count=count, access_rights_uid=access_rights_uid)
@@ -0,0 +1,691 @@
from odoo import models, fields, api, _
from odoo.exceptions import UserError
import logging
_logger = logging.getLogger(__name__)
class SaleOrder(models.Model):
_inherit = 'sale.order'
shopify_order_id = fields.Char(string='ID Pedido Shopify', copy=False, index=True)
shopify_order_number = fields.Char(string='Número Pedido Shopify', copy=False)
# ── Fulfillment ────────────────────────────────────────────────────────────
shopify_fulfillment_id = fields.Char(
string='ID Fulfillment Shopify',
copy=False,
readonly=True,
help="ID del fulfillment creado en Shopify al marcar el pedido como enviado.",
)
shopify_fulfillment_status = fields.Selection(
selection=[
('pending', 'Pendiente'),
('fulfilled', 'Enviado ✓'),
('partial', 'Parcial'),
('restocked', 'Reabastecido'),
('error', 'Error'),
],
string='Estado Fulfillment Shopify',
copy=False,
readonly=True,
default=False,
help="Estado de fulfillment sincronizado con Shopify.",
)
# ── Reembolsos y Devoluciones ──────────────────────────────────────────────
shopify_financial_status = fields.Char(
string='Estado Financiero Shopify',
readonly=True,
copy=False,
help="Estado de pago real importado de Shopify (paid, refunded, partially_refunded, etc.)"
)
shopify_refund_status = fields.Selection(
selection=[
('no_refund', 'Sin Reembolso'),
('partially_refunded', 'Parcial ⚠'),
('refunded', 'Total ✓'),
],
string='Reembolso Shopify',
default='no_refund',
readonly=True,
copy=False,
)
shopify_refund_ids = fields.Text(
string='IDs Reembolsos Shopify',
readonly=True,
copy=False,
help="Lista de IDs de reembolsos detectados en Shopify."
)
shopify_refund_ids_processed = fields.Text(
string='IDs Reembolsos Automatizados',
readonly=True,
copy=False,
help="Lista de IDs de reembolsos que ya ejecutaron la creación automática de Nota de Crédito y Devolución de Stock."
)
shopify_refund_amount = fields.Float(
string='Monto Reembolsado Shopify',
readonly=True,
copy=False
)
shopify_shipping_title = fields.Char(
string='Método de Envío Shopify',
readonly=True,
copy=False,
help="Método de entrega seleccionado por el cliente en Shopify (ej: Envío DHL, OFICINA HRSGOLF, etc.)"
)
def action_view_shopify_refunds(self):
"""Método para el botón de estado de reembolso en la orden."""
self.ensure_one()
return {
'type': 'ir.actions.client',
'tag': 'display_notification',
'params': {
'title': 'Reembolsos de Shopify',
'message': f"Monto reembolsado registrado en Shopify: ${self.shopify_refund_amount:.2f} MXN.\n"
f"Estado financiero actual: {self.shopify_financial_status or 'Desconocido'}.",
'type': 'info',
'sticky': False,
}
}
def action_confirm(self):
res = super(SaleOrder, self).action_confirm()
for order in self:
if order.shopify_order_id:
title = (order.shopify_shipping_title or '').upper()
if any(x in title for x in ['OFICINA', 'RETIRO', 'PICKUP', 'SUCURSAL', 'TIENDA']):
try:
order._create_pickup_internal_transfer()
except Exception as e:
_logger.error("[Shopify BOPIS] Error al crear traspaso interno automático para '%s': %s", order.name, str(e))
return res
def action_cancel(self):
res = super(SaleOrder, self).action_cancel()
for order in self:
# Buscar cualquier albarán/traslado asociado por sale_id o por origin (documento origen)
pending_pickings = self.env['stock.picking'].search([
'|', ('sale_id', '=', order.id), ('origin', '=', order.name),
('state', 'not in', ['done', 'cancel'])
])
if pending_pickings:
_logger.info("[Shopify Sync] Cancelando traslados/albaranes pendientes para el pedido '%s' debido a cancelación", order.name)
try:
pending_pickings.action_cancel()
except Exception as e:
_logger.warning("[Shopify Sync] No se pudo cancelar el albarán asociado al pedido '%s': %s", order.name, str(e))
return res
def _create_pickup_internal_transfer(self):
self.ensure_one()
_logger.info("[Shopify BOPIS] Iniciando creación de traspaso interno automático para pedido '%s'", self.name)
# 1. Resolver ubicaciones
# Almacén destino: el asignado en el pedido (OFICINA HRSGOLF)
warehouse_dest = self.warehouse_id
if not warehouse_dest or 'OFICINA' not in warehouse_dest.name.upper():
warehouse_dest = self.env['stock.warehouse'].search([('name', 'ilike', 'OFICINA')], limit=1)
if not warehouse_dest:
_logger.warning("[Shopify BOPIS] No se encontró el almacén de la Oficina. Omitiendo traspaso.")
return False
# Almacén origen: Bodega central (ALMA)
warehouse_orig = self.env['stock.warehouse'].search([('code', '=', 'ALMA')], limit=1)
if not warehouse_orig:
warehouse_orig = self.env['stock.warehouse'].search([('id', '!=', warehouse_dest.id)], limit=1)
if not warehouse_orig or warehouse_orig.id == warehouse_dest.id:
_logger.warning("[Shopify BOPIS] No se encontró almacén de origen distinto de la Oficina. Omitiendo traspaso.")
return False
location_orig = warehouse_orig.lot_stock_id
location_dest = warehouse_dest.lot_stock_id
# 2. Buscar Tipo de Operación de Transferencia Interna de la Bodega Central
picking_type = self.env['stock.picking.type'].search([
('warehouse_id', '=', warehouse_orig.id),
('code', '=', 'internal'),
('sequence_code', '=', 'INT')
], limit=1)
if not picking_type:
# Fallback a cualquier tipo de operación interno si no hay uno con secuencia 'INT'
picking_type = self.env['stock.picking.type'].search([
('warehouse_id', '=', warehouse_orig.id),
('code', '=', 'internal')
], limit=1)
if not picking_type:
_logger.warning("[Shopify BOPIS] No se encontró tipo de operación 'Transferencia Interna' para el almacén %s", warehouse_orig.name)
return False
# 3. Buscar movimientos internos ya existentes para este pedido (para evitar duplicados a nivel de producto)
existing_internal_moves = self.env['stock.move'].search([
'|', ('picking_id.sale_id', '=', self.id), ('picking_id.origin', '=', self.name),
('picking_id.picking_type_id.code', '=', 'internal'),
('state', '!=', 'cancel')
])
existing_product_ids = existing_internal_moves.mapped('product_id.id')
# 4. Filtrar las líneas del pedido que necesitan un nuevo traspaso interno (productos no transferidos aún)
lines_to_transfer = self.env['sale.order.line']
for line in self.order_line:
if line.display_type or not line.product_id or line.product_id.type != 'product':
continue
if line.product_id.id not in existing_product_ids:
lines_to_transfer |= line
if not lines_to_transfer:
_logger.info("[Shopify BOPIS] Todos los productos del pedido '%s' ya cuentan con un traspaso interno. Omitiendo creación.", self.name)
return False
# 5. Crear el albarán de traspaso interno
picking_vals = {
'picking_type_id': picking_type.id,
'location_id': location_orig.id,
'location_dest_id': location_dest.id,
'origin': self.name,
'sale_id': self.id,
'move_type': 'direct',
}
picking = self.env['stock.picking'].create(picking_vals)
# 6. Crear las líneas de movimiento (stock.move)
for line in lines_to_transfer:
move_vals = {
'name': line.product_id.display_name,
'product_id': line.product_id.id,
'product_uom_qty': line.product_uom_qty,
'product_uom': line.product_uom.id,
'picking_id': picking.id,
'location_id': location_orig.id,
'location_dest_id': location_dest.id,
'warehouse_id': warehouse_orig.id,
}
self.env['stock.move'].create(move_vals)
# 7. Confirmar y reservar stock
if picking.move_ids:
picking.action_confirm()
picking.action_assign()
_logger.info("[Shopify BOPIS] Traspaso interno '%s' creado y reservado para productos del pedido '%s'", picking.name, self.name)
# ── ALERTA 4: Detectar stock insuficiente para traslado ──
unreserved_moves = picking.move_ids.filtered(
lambda m: m.state not in ('assigned', 'done', 'cancel') and m.product_uom_qty > 0
)
if unreserved_moves:
alert_lines = []
for move in unreserved_moves:
available = int(move.product_id.with_context(
location=move.location_id.id
).qty_available)
alert_lines.append(
f"&nbsp;&nbsp;📉 {move.product_id.name} "
f"(SKU: {move.product_id.default_code or 'N/A'}): "
f"Necesita {int(move.product_uom_qty)}, Disponible: {available}"
)
self.message_post(
body=(
"⚠️ <b>Stock Insuficiente para Traslado</b><br/><br/>"
f"El traspaso interno <b>{picking.name}</b> fue creado pero "
f"<b>NO pudo reservar</b> stock completo:<br/>"
+ "<br/>".join(alert_lines)
+ "<br/><br/>⚡ <b>Acción requerida:</b> Coordina con almacén para "
"verificar disponibilidad o sustituir el producto desde Shopify."
),
subtype_xmlid="mail.mt_note",
message_type='notification',
)
_logger.warning(
"[Shopify BOPIS] ⚠️ Traspaso '%s' para pedido '%s' tiene %s productos sin stock suficiente.",
picking.name, self.name, len(unreserved_moves)
)
return picking
else:
picking.unlink()
_logger.warning("[Shopify BOPIS] No se generaron líneas de stock. Se eliminó el albarán de traspaso.")
return False
def _update_pickup_internal_transfer_qty(self, product, old_qty, new_qty):
"""
Actualiza la cantidad en el traslado interno (BOPIS) cuando se edita un pedido en Shopify.
Calcula matemáticamente la cantidad total ya transferida o en proceso, y realiza ajustes
exactos para prevenir duplicaciones en caso de múltiples ediciones consecutivas.
"""
self.ensure_one()
# Buscar todos los movimientos internos (BOPIS) activos para este producto
existing_moves = self.env['stock.move'].search([
'|', ('picking_id.sale_id', '=', self.id), ('picking_id.origin', '=', self.name),
('picking_id.picking_type_id.code', '=', 'internal'),
('product_id', '=', product.id),
('state', '!=', 'cancel'),
])
if not existing_moves:
_logger.info("[Shopify BOPIS Qty] No hay traslados internos para %s. Omitiendo.", product.display_name)
return
# Calcular la cantidad total que ya está en traslados (hechos o pendientes)
total_transfer_qty = sum(existing_moves.mapped('product_uom_qty'))
true_diff = new_qty - total_transfer_qty
if true_diff == 0:
_logger.info("[Shopify BOPIS Qty] Cantidad balanceada para %s (Total: %s).", product.display_name, new_qty)
return
if true_diff > 0:
# ── Faltan unidades: Buscar un traslado pendiente o crear uno nuevo ──
pending_moves = existing_moves.filtered(lambda m: m.picking_id.state in ('draft', 'waiting', 'confirmed', 'assigned'))
if pending_moves:
# Sumar la diferencia al traslado pendiente existente
move = pending_moves[0]
picking = move.picking_id
new_move_qty = move.product_uom_qty + true_diff
_logger.info("[Shopify BOPIS Qty] Actualizando traslado pendiente '%s' (+%s unidades)", picking.name, true_diff)
move.write({'product_uom_qty': new_move_qty})
if picking.state == 'assigned':
picking.action_assign()
picking.message_post(
body=(
f"🔄 <b>Actualización por Edición en Shopify</b><br/><br/>"
f"La cantidad requerida de <b>{product.display_name}</b> aumentó.<br/>"
f"Se añadieron <b>+{int(true_diff)} unidad(es)</b> a este traslado "
f"(Pasó de {int(move.product_uom_qty - true_diff)} a {int(new_move_qty)})."
),
subtype_xmlid="mail.mt_note",
message_type='notification',
author_id=self.env.ref('base.partner_root').id,
)
else:
# Todos están HECHOS. Crear traslado complementario por la verdadera diferencia.
base_picking = existing_moves[0].picking_id
_logger.info("[Shopify BOPIS Qty] Creando traslado complementario de +%s para %s", true_diff, product.display_name)
comp_picking_vals = {
'picking_type_id': base_picking.picking_type_id.id,
'location_id': base_picking.location_id.id,
'location_dest_id': base_picking.location_dest_id.id,
'origin': self.name,
'sale_id': self.id,
'move_type': 'direct',
}
comp_picking = self.env['stock.picking'].create(comp_picking_vals)
self.env['stock.move'].create({
'name': f"[Complemento Shopify Edit] {product.display_name}",
'product_id': product.id,
'product_uom_qty': true_diff,
'product_uom': existing_moves[0].product_uom.id,
'picking_id': comp_picking.id,
'location_id': base_picking.location_id.id,
'location_dest_id': base_picking.location_dest_id.id,
'warehouse_id': existing_moves[0].warehouse_id.id if existing_moves[0].warehouse_id else False,
})
comp_picking.action_confirm()
comp_picking.action_assign()
self.message_post(
body=(
f"📦 <b>Traslado Complementario Creado</b><br/><br/>"
f"Shopify editó la cantidad de <b>{product.display_name}</b> a {int(new_qty)}.<br/>"
f"Se creó el traslado complementario <b>{comp_picking.name}</b> por "
f"<b>+{int(true_diff)} unidad(es)</b>."
),
subtype_xmlid="mail.mt_note",
message_type='notification',
)
comp_picking.message_post(
body=(
f"📦 <b>Traslado Complementario Automático</b><br/><br/>"
f"El pedido <b>{self.name}</b> fue editado en Shopify.<br/>"
f"Se requieren <b>+{int(true_diff)} unidad(es)</b> adicionales de "
f"<b>{product.display_name}</b> respecto a los traslados anteriores."
),
subtype_xmlid="mail.mt_note",
message_type='notification',
author_id=self.env.ref('base.partner_root').id,
)
elif true_diff < 0:
# ── Sobran unidades (El cliente redujo la cantidad) ──
# Se requiere atención manual independientemente de si el traslado está pendiente o no
base_picking = existing_moves[0].picking_id
_logger.warning("[Shopify BOPIS Qty] La cantidad disminuyó. Sobran %s unidades de %s.", abs(true_diff), product.display_name)
self.message_post(
body=(
f"⚠️ <b>Atención: Reducción de Cantidad en Retiro de Oficina</b><br/><br/>"
f"Shopify redujo la cantidad de <b>{product.display_name}</b> a {int(new_qty)}.<br/>"
f"Actualmente hay {int(total_transfer_qty)} unidades en traslados internos (pendientes o completados).<br/>"
f"⚡ <b>Acción requerida:</b> Debes cancelar u organizar la devolución de "
f"<b>{int(abs(true_diff))} unidad(es)</b> de forma manual."
),
subtype_xmlid="mail.mt_note",
message_type='notification',
author_id=self.env.ref('base.partner_root').id,
)
# ── Guía de Envío ──────────────────────────────────────────────────────────
shopify_tracking_number = fields.Char(
string='Número de Guía',
copy=False,
help="Número de guía del paquetero (DHL, FedEx, etc.). "
"Se enviará a Shopify junto con el fulfillment para que el cliente pueda rastrear su pedido.",
)
shopify_carrier_name = fields.Selection(
selection=[
('DHL', 'DHL Express'),
('FedEx', 'FedEx'),
('Estafeta', 'Estafeta'),
('Redpack', 'Redpack'),
('Paquetexpress', 'Paquetexpress'),
('J&T', 'J&T Express'),
('MensajeriaLocal', 'Mensajería Local (Taxi/Mensajero)'),
('Other', 'Otro'),
],
string='Paquetería',
copy=False,
help="Paquetería con la que se realiza el envío. "
"Usa 'Mensajería Local' para envíos por taxi o mensajero de confianza.",
)
def action_notify_shopify_fulfillment(self):
"""
Botón manual: marca el pedido como 'Fulfilled' en Shopify
e incluye el número de guía si está capturado.
Flujo:
1. Verifica que el pedido viene de Shopify (tiene shopify_order_id).
2. Verifica que al menos una entrega está validada (state='done').
3. Toma el número de guía del campo shopify_tracking_number (o del albarán si no).
4. Llama _notify_shopify_fulfillment() en el config.
"""
self.ensure_one()
if not self.shopify_order_id:
raise UserError(
"Este pedido no está vinculado a Shopify.\n"
"Solo se pueden enviar fulfillments de pedidos importados desde Shopify."
)
if self.shopify_fulfillment_status == 'fulfilled':
raise UserError(
"Este pedido ya fue marcado como 'Enviado' en Shopify (fulfillment_id: %s).\n"
"No es necesario enviarlo de nuevo." % self.shopify_fulfillment_id
)
# Verificar que haya al menos una entrega completada
done_pickings = self.picking_ids.filtered(lambda p: p.state == 'done')
if not done_pickings:
raise UserError(
"No hay entregas validadas para este pedido.\n"
"Valida la entrega en Odoo antes de notificar el fulfillment a Shopify."
)
# Buscar el shopify.config activo
config = self.env['shopify.config'].search([], limit=1)
if not config:
raise UserError("No hay configuración de Shopify disponible.")
# Prioridad: campo directo en la orden → campo en el albarán (módulo delivery)
tracking_number = self.shopify_tracking_number or None
carrier_name = self.shopify_carrier_name or None
if not tracking_number:
# Fallback: leer del albarán si el módulo delivery está instalado
for picking in done_pickings:
if hasattr(picking, 'carrier_tracking_ref') and picking.carrier_tracking_ref:
tracking_number = picking.carrier_tracking_ref
if hasattr(picking, 'carrier_id') and picking.carrier_id:
carrier_name = picking.carrier_id.name
return config._notify_shopify_fulfillment(
sale_order=self,
tracking_number=tracking_number,
carrier_name=carrier_name,
notify_customer=True,
)
class StockPicking(models.Model):
_inherit = 'stock.picking'
shopify_order_number = fields.Char(
string='Número Shopify',
compute='_compute_shopify_fields',
store=True,
readonly=True,
)
shopify_shipping_title = fields.Char(
string='Método Envío Shopify',
compute='_compute_shopify_fields',
store=True,
readonly=True,
)
shopify_delivery_type = fields.Selection(
selection=[
('none', 'No Shopify ⚪'),
('pickup', 'Retiro en Oficina 🔵'),
('delivery', 'Envío con Guía 🚚'),
],
string='Tipo Entrega Shopify',
compute='_compute_shopify_fields',
store=True,
)
shopify_ready_for_pickup_notified = fields.Boolean(
string='Notificado Listo para Retiro',
default=False,
copy=False,
)
shopify_delivery_address = fields.Char(
related='partner_id.contact_address',
string='Dirección de Envío Completa',
readonly=True,
)
shopify_delivery_phone = fields.Char(
related='partner_id.phone',
string='Teléfono de Envío',
readonly=True,
)
shopify_delivery_mobile = fields.Char(
related='partner_id.mobile',
string='Celular de Envío',
readonly=True,
)
@api.depends('sale_id.shopify_order_id', 'sale_id.shopify_order_number', 'sale_id.shopify_shipping_title', 'origin')
def _compute_shopify_fields(self):
for picking in self:
sale = picking.sale_id.sudo() if picking.sale_id else False
if not sale and picking.origin:
sale = self.env['sale.order'].sudo().search([('name', '=', picking.origin)], limit=1)
if sale and sale.shopify_order_id:
picking.shopify_order_number = sale.shopify_order_number
picking.shopify_shipping_title = sale.shopify_shipping_title
title = (sale.shopify_shipping_title or '').upper()
if not title:
# Pedidos sin método de envío (como Punto de Venta POS)
picking.shopify_delivery_type = 'none'
elif any(x in title for x in ['OFICINA', 'RETIRO', 'PICKUP', 'SUCURSAL', 'TIENDA']):
picking.shopify_delivery_type = 'pickup'
else:
picking.shopify_delivery_type = 'delivery'
else:
picking.shopify_order_number = False
picking.shopify_shipping_title = False
picking.shopify_delivery_type = 'none'
def action_shopify_ready_for_pickup(self):
self.ensure_one()
sale = self.sale_id.sudo() if self.sale_id else False
if not sale and self.origin:
sale = self.env['sale.order'].sudo().search([('name', '=', self.origin)], limit=1)
if not sale or not sale.shopify_order_id:
raise UserError("Este albarán no está vinculado a un pedido de Shopify.")
config = self.env['shopify.config'].sudo().search([('state', '=', 'confirmed')], limit=1)
if not config:
raise UserError("No hay configuración de Shopify confirmada.")
# Notificar a Shopify
res = config._notify_shopify_ready_for_pickup(sale)
if res:
self.shopify_ready_for_pickup_notified = True
self.message_post(
body="✅ <strong>Shopify:</strong> Se ha notificado al cliente que el pedido está 'Listo para Retiro'.",
subtype_xmlid="mail.mt_note"
)
else:
raise UserError("Hubo un error al notificar a Shopify. Revisa los logs o verifica que la ubicación de Shopify tenga 'Local Pickup' activado.")
return True
def button_validate(self):
"""
Extensión del botón Validar:
1. Valida de forma obligatoria que se tenga número de guía si el pedido
es de tipo envío por paquetería ('delivery') y es un albarán de salida.
2. Si pasa la validación y el albarán se valida con éxito:
- Si es un traspaso interno de retiro local, notifica "Listo para retiro" a Shopify.
- Si es un albarán de salida final (entrega), notifica fulfillment automático a Shopify.
- Inicia la sincronización de stock de los productos.
"""
for picking in self:
sale = picking.sale_id.sudo() if picking.sale_id else False
if not sale and picking.origin:
sale = self.env['sale.order'].sudo().search([('name', '=', picking.origin)], limit=1)
if sale and sale.shopify_order_id:
# Comprobar obligatoriedad de la guía solo para albaranes de salida (entregas)
if picking.picking_type_id.code == 'outgoing':
picking._compute_shopify_fields() # Asegurar cálculo fresco
if picking.shopify_delivery_type == 'delivery':
tracking = picking.carrier_tracking_ref or None
if not tracking:
raise UserError(
"⚠️ [Alerta Almacén] Este pedido de Shopify requiere ENVÍO A DOMICILIO (%s).\n\n"
"Por favor, ingresa el 'Referencia de guía' (Número de guía) y la paquetería "
"en la pestaña 'Información adicional' antes de Validar este albarán." % picking.shopify_shipping_title
)
res = super(StockPicking, self).button_validate()
for picking in self:
if picking.state == 'done':
sale = picking.sale_id.sudo() if picking.sale_id else False
if not sale and picking.origin:
sale = self.env['sale.order'].sudo().search([('name', '=', picking.origin)], limit=1)
if sale and sale.shopify_order_id:
config = self.env['shopify.config'].sudo().search([('state', '=', 'confirmed')], limit=1)
if config:
# A. ── TRASPASO INTERNO (Transferido en Shopify) ──
if picking.picking_type_id.code == 'internal':
title = (sale.shopify_shipping_title or '').upper()
if any(x in title for x in ['OFICINA', 'RETIRO', 'PICKUP', 'SUCURSAL', 'TIENDA']):
_logger.info(
"[Shopify BOPIS] Traspaso Interno '%s' validado. "
"Enviando movimiento 'Transferido al lugar de retiro' a Shopify para pedido '%s'",
picking.name, sale.name
)
try:
config._notify_shopify_fulfillment_order_move(sale)
except Exception as e:
_logger.warning(
"[Shopify BOPIS] Error al notificar movimiento de sucursal para '%s': %s",
sale.name, str(e)
)
# B. ── ENTREGA AL CLIENTE (Fulfillment Final / Retirado) ──
elif picking.picking_type_id.code == 'outgoing':
if sale.shopify_fulfillment_status != 'fulfilled':
tracking = picking.carrier_tracking_ref or None
carrier = picking.carrier_id.name if picking.carrier_id else None
_logger.info(
"[Shopify Auto-Fulfillment] Albarán de salida '%s' validado. "
"Notificando envío/entrega automática a Shopify para pedido '%s' (Guía: %s, Paquetería: %s)",
picking.name, sale.name, tracking, carrier
)
try:
config._notify_shopify_fulfillment(
sale_order=sale,
tracking_number=tracking,
carrier_name=carrier,
notify_customer=True,
)
except Exception as e:
_logger.warning(
"[Shopify Auto-Fulfillment] Error al notificar envío automático para '%s': %s",
sale.name, str(e)
)
# ── ALERTA 2: Fulfillment fallido → notificar en chatter ──
try:
sale.message_post(
body=(
"❌ <b>Fulfillment Shopify Fallido</b><br/><br/>"
f"No se pudo marcar el pedido como enviado en Shopify "
f"al validar el albarán <b>{picking.name}</b>.<br/>"
f"<b>Error:</b> {str(e)}<br/><br/>"
"⚡ Usa el botón <b>'Notificar Fulfillment a Shopify'</b> "
"en la pestaña 'Otra Información' para reintentar."
),
subtype_xmlid="mail.mt_note",
message_type='notification',
)
except Exception:
pass # No romper el flujo si el chatter falla
# 2. ── AUTO-STOCK SYNC (Traslados y Movimientos de Stock) ──
products = picking.move_ids.mapped('product_id').filtered(lambda p: p.shopify_inventory_item_id)
if products:
config = self.env['shopify.config'].sudo().search([('state', '=', 'confirmed')], limit=1)
if config and config.auto_sync_products:
_logger.info(
"[Shopify Stock Auto-Sync] Albarán '%s' validado. "
"Sincronizando stock de %s variantes vinculadas a Shopify.",
picking.name, len(products)
)
try:
import threading
import odoo
def sync_bg(dbname, p_ids, c_id):
try:
registry = odoo.registry(dbname)
with registry.cursor() as cr:
env = odoo.api.Environment(cr, odoo.SUPERUSER_ID, {})
prods = env['product.product'].browse(p_ids)
conf = env['shopify.config'].browse(c_id)
if conf and prods:
conf._sync_variants_stock_to_shopify(prods)
except Exception as exc:
import logging
logging.getLogger(__name__).error("[Shopify Stock Auto-Sync Async] Fallo: %s", exc)
if hasattr(self.env.cr, 'postcommit'):
self.env.cr.postcommit.add(
lambda: threading.Thread(
target=sync_bg,
args=(self.env.cr.dbname, products.ids, config.id)
).start()
)
else:
threading.Thread(
target=sync_bg,
args=(self.env.cr.dbname, products.ids, config.id)
).start()
except Exception as e:
_logger.warning(
"[Shopify Stock Auto-Sync] Error al iniciar hilo de sincronización para '%s': %s",
picking.name, str(e)
)
return res
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,45 @@
from odoo import models, fields
class ShopifyCostLine(models.Model):
_name = 'shopify.cost.line'
_description = 'Línea de Sincronización de Costos de Shopify'
config_id = fields.Many2one(
'shopify.config',
string='Configuración Shopify',
ondelete='cascade'
)
product_id = fields.Many2one(
'product.product',
string='Variante (Producto)',
required=True,
ondelete='cascade'
)
product_tmpl_id = fields.Many2one(
related='product_id.product_tmpl_id',
string='Plantilla (Modelo)',
store=True,
)
default_code = fields.Char(
related='product_id.default_code',
string='SKU',
store=True,
)
current_cost = fields.Float(
string='Costo Actual (Odoo)',
digits='Product Price'
)
new_cost = fields.Float(
string='Costo Nuevo (Shopify)',
digits='Product Price'
)
shopify_inventory_item_id = fields.Char(
string='Inventory Item ID',
help='ID interno de inventario en Shopify'
)
state = fields.Selection([
('pending', 'Pendiente'),
('done', 'Subido ✓'),
('error', 'Error ❌')
], string='Estado', default='pending')
error_message = fields.Char(string='Detalle de Error')
@@ -0,0 +1,34 @@
from odoo import models, fields, api
class ShopifyLocationWizard(models.TransientModel):
_name = 'shopify.location.wizard'
_description = 'Selector de Ubicaciones Shopify'
config_id = fields.Many2one('shopify.config', string='Configuración', required=True)
line_ids = fields.One2many('shopify.location.wizard.line', 'wizard_id', string='Sucursales Disponibles')
def action_confirm(self):
self.ensure_one()
selected_line = self.line_ids.filtered(lambda l: l.is_selected)
if not selected_line:
from odoo.exceptions import UserError
raise UserError('Por favor, selecciona una sucursal.')
if len(selected_line) > 1:
from odoo.exceptions import UserError
raise UserError('Solo puedes seleccionar una sucursal.')
self.config_id.write({
'shopify_location_id': selected_line.shopify_location_id,
'shopify_location_name': selected_line.name
})
return {'type': 'ir.actions.act_window_close'}
class ShopifyLocationWizardLine(models.TransientModel):
_name = 'shopify.location.wizard.line'
_description = 'Línea de Ubicación Shopify'
wizard_id = fields.Many2one('shopify.location.wizard', string='Wizard')
shopify_location_id = fields.Char(string='ID Shopify', readonly=True)
name = fields.Char(string='Nombre Sucursal', readonly=True)
is_selected = fields.Boolean(string='Seleccionada')
@@ -0,0 +1,43 @@
# -*- coding: utf-8 -*-
from odoo import models, fields
class ShopifyProductPublication(models.Model):
"""
Tabla pivote: estado de publicación de un producto en un canal de venta de Shopify.
Cada registro indica si un producto está publicado (o no) en un canal específico.
"""
_name = 'shopify.product.publication'
_description = 'Publicación de Producto en Canal Shopify'
_order = 'publication_id'
product_tmpl_id = fields.Many2one(
'product.template',
string='Producto',
required=True,
ondelete='cascade',
index=True,
)
publication_id = fields.Many2one(
'shopify.publication',
string='Canal de Venta',
required=True,
ondelete='cascade',
)
publication_name = fields.Char(
related='publication_id.name',
string='Nombre del Canal',
readonly=True,
store=True,
)
is_published = fields.Boolean(
string='Publicado',
default=False,
help="Si está marcado, el producto se publicará en este canal al hacer push a Shopify.",
)
_sql_constraints = [
('unique_product_publication',
'UNIQUE(product_tmpl_id, publication_id)',
'El producto ya tiene un registro para este canal de venta.'),
]
@@ -0,0 +1,39 @@
# -*- coding: utf-8 -*-
from odoo import models, fields
class ShopifyPublication(models.Model):
"""
Representa un canal de venta (publication) de Shopify.
Ej: Point of Sale, Online Store, Facebook & Instagram, etc.
Se carga dinámicamente desde la API GraphQL de Shopify.
"""
_name = 'shopify.publication'
_description = 'Canal de Venta Shopify'
_order = 'name'
name = fields.Char(
string='Nombre del canal',
required=True,
readonly=True,
)
shopify_publication_id = fields.Char(
string='ID Publicación Shopify (GID)',
required=True,
readonly=True,
index=True,
help="ID global de Shopify, ej: gid://shopify/Publication/12345",
)
config_id = fields.Many2one(
'shopify.config',
string='Tienda Shopify',
required=True,
ondelete='cascade',
readonly=True,
)
_sql_constraints = [
('unique_pub_per_config',
'UNIQUE(shopify_publication_id, config_id)',
'Ya existe este canal de venta para esta tienda.'),
]
@@ -0,0 +1,147 @@
import re
CATEGORIES = {
'hat': 'HAT', 'gorra': 'HAT', 'sombrero': 'HAT', 'hats': 'HAT', 'gorras': 'HAT',
'visera': 'HATV', 'visor': 'HATV',
'vest': 'VST', 'chaleco': 'VST', 'vests': 'VST', 'chalecos': 'VST',
'jacket': 'JCK', 'chamarra': 'JCK', 'jackets': 'JCK', 'chamarras': 'JCK', 'raincoat': 'JCK', 'impermeable': 'JCK',
'hoodie': 'HOD', 'hoodies': 'HOD', 'sudadera': 'HOD', 'sudaderas': 'HOD', 'chumpa': 'HOD',
'bag': 'BAG', 'bolsa': 'BAG', 'boston bag': 'BAG', 'bags': 'BAG', 'bolsas': 'BAG',
'cover': 'COV', 'funda': 'COV', 'covers': 'COV', 'fundas': 'COV',
'marker': 'BM', 'markers': 'BM',
'glove': 'GLV', 'guante': 'GLV', 'gloves': 'GLV', 'guantes': 'GLV',
'towel': 'TWL', 'toalla': 'TWL', 'towels': 'TWL', 'toallas': 'TWL',
'tank': 'TNK', 'top': 'TNK',
'polo': 'POL', 'polos': 'POL', 'fmg': 'POL',
'tee': 'TSH', 't-shirt': 'TSH', 'tshirt': 'TSH', 'playera': 'TSH', 'playeras': 'TSH', 't-shirts': 'TSH', 'shirts': 'TSH', 'shirt': 'TSH', 'padel': 'TSH',
'keyring': 'ACC', 'llavero': 'ACC', 'keyrings': 'ACC', 'llaveros': 'ACC', 'accesorios': 'ACC', 'accessories': 'ACC', 'pines': 'ACC', 'pin': 'ACC'
}
GENDERS = {
'men': 'M', "mens": 'M', 'caballero': 'M', 'hombre': 'M',
'women': 'W', 'dama': 'W', 'mujer': 'W',
'kids': 'K', 'nino': 'K', 'nina': 'K', 'niño': 'K', 'niña': 'K'
}
COLORS = {
'black': 'BLK', 'negra': 'BLK', 'negro': 'BLK',
'white': 'WHT', 'blanca': 'WHT', 'blanco': 'WHT', 'off white': 'WHT', 'white/white': 'WHT',
'green': 'GRN', 'verde': 'GRN', 'dark green': 'GRN',
'blue': 'BLU', 'azul': 'BLU', 'navy': 'BLU', 'sky blue': 'BLU', 'cielo': 'BLU',
'red': 'RED', 'roja': 'RED', 'rojo': 'RED', 'burgundy': 'RED', 'vino': 'RED',
'grey': 'GRY', 'gris': 'GRY',
'pink': 'PNK', 'rosa': 'PNK',
'beige': 'BGE'
}
IGNORE_WORDS = [
"stock", "-", "mesa", "muestra", "custom", "program", "hrs", "hazard", "rough", "society",
"the", "para", "gorra", "sombrero", "hat", "polo", "playera", "tee", "shirt",
"towel", "toalla", "buckle", "hebilla", "boton", "button", "belt", "cinturon", "outerwear"
]
def safe_extract(text, mapping_dict, default):
"""Extrae el mapeo basado en la primera ocurrencia encontrada en el texto."""
text_lower = text.lower()
matches = []
for key, val in mapping_dict.items():
found_idx = text_lower.find(key.lower())
if found_idx != -1:
# Validar que sea una palabra completa
if re.search(r'\b' + re.escape(key) + r'\b', text_lower):
matches.append((found_idx, val))
# Retornar el que aparece MÁS TEMPRANO en el texto
if matches:
matches.sort() # Ordena por índice
return matches[0][1]
return default
def generate_sku_for_variant(product_data, variant_data, brand_prefix="HRS"):
"""
Genera un SKU inteligente basado en la lógica de Hazard Rough Society.
"""
title = product_data.get('title', '')
handle = product_data.get('handle', '')
product_type = product_data.get('product_type', '')
tags = product_data.get('tags', '')
full_text = f"{title} {handle} {product_type} {tags}".replace('-', ' ')
# 1. TIPO
tipo = safe_extract(full_text, CATEGORIES, 'ACC')
# 2. GÉNERO
genero = safe_extract(full_text, GENDERS, 'U')
if tipo in ['BAG', 'COV', 'BM', 'TWL', 'HAT']:
genero = 'U'
# 3. COLOR
color = 'MUL'
# Prioridad 1: Opciones de variante
for opt_idx in range(1, 4):
opt_name = product_data.get(f'option{opt_idx}', '').lower()
opt_val = variant_data.get(f'option{opt_idx}', '')
if 'color' in opt_name:
color = safe_extract(opt_val, COLORS, 'MUL')
break
# Prioridad 2: Texto completo (considerando 'Earliest match' en safe_extract)
if color == 'MUL':
# Truco: Si hay palabras como 'BUCKLE' o 'BUTTON', tratamos de buscar el color DESPUÉS de ellas
# o simplemente confiamos en que el color dominante suele ir primero o después del accesorio.
color = safe_extract(full_text, COLORS, 'MUL')
# 4. MODELO
words = full_text.upper().split()
model_words = []
# Lista de códigos de colores para evitar usarlos como modelo
color_codes = set(COLORS.values())
color_names = set(COLORS.keys())
for w in words:
w_clean = re.sub(r'[^A-Z0-9]', '', w)
if not w_clean: continue
lower_w = w_clean.lower()
# Ignorar si: está en la lista de ignorados, es un color, o es el prefijo de marca
if any(lower_w == ig for ig in IGNORE_WORDS): continue
if lower_w in color_names: continue
if w_clean in color_codes: continue
if w_clean == brand_prefix: continue
model_words.append(w_clean)
if len(model_words) == 1:
break
modelo = model_words[0][:6] if model_words else "GEN"
# Overrides específicos de modelos
if modelo == 'CLUB78': modelo = 'CLUB'
elif modelo == 'MASTERS': modelo = 'MASTER'
# 5. TALLA
size = "OS"
for opt_idx in range(1, 4):
opt_name = product_data.get(f'option{opt_idx}', '').lower()
opt_val = (variant_data.get(f'option{opt_idx}') or '').strip()
if opt_val and opt_val.lower() != 'default title':
if any(x in opt_name for x in ['talla', 'size']):
size = opt_val.upper()
break
elif 'color' not in opt_name:
if size == 'OS':
size = opt_val.upper()
# Limpieza de talla personalizada (Remover UNISEX)
size_clean = size.upper().replace('UNISEX', '').strip()
size_clean = re.sub(r'[^A-Z0-9\-]', '', size_clean)
if not size_clean: size_clean = "OS"
base_sku = f"{brand_prefix}-{tipo}-{modelo}-{genero}-{color}"
return f"{base_sku}-{size_clean}"
base_sku = f"{brand_prefix}-{tipo}-{modelo}-{genero}-{color}"
return f"{base_sku}-{size_clean}"
@@ -0,0 +1,492 @@
from odoo import models, fields, api, _
from odoo.exceptions import UserError
import logging
import requests
import base64
import time
_logger = logging.getLogger(__name__)
class ShopifySKUSimulationLine(models.Model):
_name = 'shopify.sku.simulation.line'
_description = 'Línea de Simulación de SKU de Shopify'
config_id = fields.Many2one('shopify.config', string='Configuración', ondelete='cascade')
product_name = fields.Char(string='Producto', readonly=True)
variant_name = fields.Char(string='Variante', readonly=True)
shopify_product_id = fields.Char(string='ID Producto Shopify', readonly=True)
shopify_variant_id = fields.Char(string='ID Variante Shopify', readonly=True)
shopify_handle = fields.Char(string='Handle (Shopify)', readonly=True)
shopify_title = fields.Char(string='Título (Shopify)', readonly=True)
old_sku = fields.Char(string='SKU Actual', readonly=True)
suggested_sku = fields.Char(string='SKU Sugerido (Editable)')
barcode = fields.Char(string='Código de Barras (Shopify)', readonly=True)
shopify_inventory_item_id = fields.Char(string='ID Inventario Shopify', readonly=True)
# EAN-13 generado por nuestra herramienta
generated_barcode = fields.Char(string='EAN-13 Generado', readonly=True)
barcode_sync_state = fields.Selection([
('none', 'Sin generar'),
('pending', 'Listo para subir'),
('done', 'Subido ✓'),
('error', 'Error'),
], string='Estado Barcode', default='none', readonly=True)
barcode_sync_error = fields.Char(string='Error Barcode', readonly=True)
# New fields for better filtering
product_type = fields.Char(string='Tipo de Producto', readonly=True)
product_image = fields.Char(string='URL Imagen', readonly=True)
sku_category_code = fields.Char(string='Código Cat.', readonly=True)
image_html = fields.Html(string='Imagen (HTML)', compute='_compute_image_html')
is_selected = fields.Boolean(string='Sincronizar', default=False)
sku_source = fields.Selection([
('csv', 'CSV'),
('logic', 'Lógica')
], string='Origen', default='logic')
sync_state = fields.Selection([
('draft', 'Sin SKU'),
('has_sku', 'Tiene SKU'),
('synced', 'Sincronizado'),
('error', 'Error')
], string='Estado', default='draft', readonly=True)
is_imported = fields.Boolean(string='Importado a Odoo', default=False, readonly=True)
# Nuevo: Campo para saber si ya lo trajimos a Odoo
exists_in_odoo = fields.Boolean(string='En Odoo', compute='_compute_exists_in_odoo', store=False)
sync_history = fields.Text(string='Historial de Sync', readonly=True)
last_sync_date = fields.Datetime(string='Último Ajuste Realizado', readonly=True)
shopify_qty_total = fields.Float(string='Total Shopify', default=0.0)
odoo_qty_total = fields.Float(string='Total Odoo', compute='_compute_odoo_qty_total', store=False)
shopify_stock_info = fields.Html(string='Detalle Stock Shopify', readonly=True)
odoo_stock_info = fields.Html(string='Stock en Odoo', compute='_compute_odoo_stock_info')
# Campo para detectar duplicados EN SHOPIFY (previos a la corrección)
is_shopify_duplicate = fields.Boolean(string='Duplicado en Web', compute='_compute_shopify_duplicate', store=False)
duplicate_with = fields.Char(string='Conflicto con...', compute='_compute_shopify_duplicate', store=False)
@api.depends('old_sku', 'config_id', 'config_id.simulation_line_ids.old_sku')
def _compute_shopify_duplicate(self):
"""Detecta duplicados agrupando en memoria para evitar queries con NewId."""
# Agrupar por config para procesar en bloque (eficiente)
by_config = {}
for record in self:
config_id = record.config_id.id or 0
by_config.setdefault(config_id, self.env['shopify.sku.simulation.line'])
by_config[config_id] |= record
for config_id, records in by_config.items():
# Obtener todas las líneas del config usando el set de registros ya en memoria
if records and records[0].config_id:
all_lines = records[0].config_id.simulation_line_ids
else:
all_lines = records
# Construir mapa SKU -> [líneas]
sku_map = {}
for line in all_lines:
if line.old_sku:
sku_map.setdefault(line.old_sku, [])
sku_map[line.old_sku].append(line)
# Marcar los que están en el conjunto actual
for record in records:
if not record.old_sku:
record.is_shopify_duplicate = False
record.duplicate_with = ''
continue
siblings = [l for l in sku_map.get(record.old_sku, []) if l.id != record.id]
record.is_shopify_duplicate = bool(siblings)
if siblings:
names = [f"{s.product_name} ({s.variant_name})" for s in siblings[:3]]
note = ', '.join(names)
if len(siblings) > 3:
note += f'... y otros {len(siblings) - 3}'
record.duplicate_with = note
else:
record.duplicate_with = ''
@api.depends('old_sku', 'config_id.location_mapping_ids')
def _compute_odoo_stock_info(self):
for record in self:
if not record.old_sku:
record.odoo_stock_info = "Sin SKU"
continue
product = self.env['product.product'].search([('default_code', '=', record.old_sku)], limit=1)
if not product:
record.odoo_stock_info = "No existe en Odoo"
continue
html = """<table class="table table-sm" style="width:100%; font-size: 13px;">
<thead><tr style="background-color: #f8f9fa;"><th>Ubicación</th><th class="text-right">Stock</th></tr></thead>
<tbody>"""
mappings = record.config_id.location_mapping_ids
has_data = False
for m in mappings:
if m.odoo_location_id:
quants = self.env['stock.quant'].search([
('product_id', '=', product.id),
('location_id', '=', m.odoo_location_id.id)
])
qty = sum(quants.mapped('quantity'))
color = "#28a745" if qty > 0 else "#6c757d"
html += f'<tr><td>{m.odoo_location_id.display_name}</td><td class="text-right"><span class="badge badge-pill" style="background-color: {color}; color: white; padding: 5px 10px;">{qty}</span></td></tr>'
has_data = True
if not has_data:
html += f'<tr><td colspan="2" class="text-muted text-center">Total Odoo: {product.qty_available}</td></tr>'
html += "</tbody></table>"
record.odoo_stock_info = html
@api.depends('old_sku')
def _compute_exists_in_odoo(self):
for record in self:
# BUSQUEDA: Solo por SKU real (old_sku)
found = False
if record.old_sku:
found = self.env['product.product'].search([('default_code', '=', record.old_sku)], limit=1)
record.exists_in_odoo = bool(found)
@api.depends('old_sku')
def _compute_odoo_qty_total(self):
for record in self:
if not record.old_sku:
record.odoo_qty_total = 0.0
continue
product = self.env['product.product'].search([('default_code', '=', record.old_sku)], limit=1)
record.odoo_qty_total = product.qty_available if product else 0.0
def action_import_to_odoo(self):
"""Paso 1 (Importación): Crea el ítem en Odoo y le pone el stock inicial de Shopify."""
self.ensure_one()
if self.exists_in_odoo:
raise UserError("Este producto ya existe en Odoo con el SKU %s" % self.old_sku)
config = self.config_id
if not config.shopify_location_id:
raise UserError("Debes vincular una sucursal de Shopify primero (Botón 'Traer Ubicaciones').")
# 1. Buscar Almacén y Ubicación principal de Odoo
warehouse = self.env['stock.warehouse'].search([], limit=1)
shop_domain = config.shop_url.strip().lower()
if '://' in shop_domain:
shop_domain = shop_domain.split('://')[1].split('/')[0]
else:
shop_domain = shop_domain.split('/')[0]
token = config._ensure_access_token()
headers = {"X-Shopify-Access-Token": token}
# 1. Obtener la variante de Shopify para tener el inventory_item_id real
url_v = f"https://{shop_domain}/admin/api/2024-01/variants/{self.shopify_variant_id}.json"
resp_v = requests.get(url_v, headers=headers, timeout=15)
if resp_v.status_code != 200:
raise Exception("Error consultando Shopify: %s" % resp_v.text)
variant_data = resp_v.json().get('variant', {})
inv_item_id = variant_data.get('inventory_item_id')
# 2. Obtener Stock de Shopify Multi-Sucursal
mappings = config.location_mapping_ids
total_qty = 0
# 3. Crear o Actualizar el Producto en Odoo
try:
shopify_price = float(variant_data.get('price', 0.0))
except:
shopify_price = 0.0
full_name = self.product_name
if self.variant_name and self.variant_name.lower() not in ('default title', 'default', ''):
full_name = f"{self.product_name} ({self.variant_name})"
# BUSQUEDA HÍBRIDA PARA ACTUALIZACIÓN:
# 1. Buscamos por el SKU Correcto (M)
odoo_variant = self.env['product.product'].search([('default_code', '=', self.old_sku)], limit=1)
# 2. Si no existe, buscamos por el SKU Viejo (U) para CORREGIRLO
if not odoo_variant and self.suggested_sku:
odoo_variant = self.env['product.product'].search([('default_code', '=', self.suggested_sku)], limit=1)
if odoo_variant:
_logger.info("Corrigiendo SKU en Odoo: %s -> %s", odoo_variant.default_code, self.old_sku)
if odoo_variant:
# Si existe (incluso si tenía el SKU viejo), lo actualizamos al SKU CORRECTO
odoo_variant.write({
'active': True,
'name': full_name,
'default_code': self.old_sku, # <--- AQUÍ SE CORRIGE EL SKU
'list_price': shopify_price,
'barcode': self.barcode or self.generated_barcode,
'shopify_variant_id': self.shopify_variant_id,
'shopify_inventory_item_id': str(inv_item_id),
'sale_ok': True,
'purchase_ok': True,
})
odoo_variant.product_tmpl_id.write({
'name': full_name,
'active': True,
'sale_ok': True,
'purchase_ok': True,
})
else:
# Si no existe de ninguna forma, creamos uno nuevo con el SKU CORRECTO
template = self.env['product.template'].create({
'name': full_name,
'type': 'product',
'list_price': shopify_price,
'barcode': self.barcode or self.generated_barcode,
'default_code': self.old_sku, # <--- SKU CORRECTO DE SHOPIFY
'categ_id': self.env.ref('product.product_category_all').id,
'sale_ok': True,
'purchase_ok': True,
'active': True,
'company_id': False, # GLOBAL
})
odoo_variant = template.product_variant_id
odoo_variant.write({
'shopify_variant_id': self.shopify_variant_id,
'shopify_inventory_item_id': str(inv_item_id),
})
# 4. Sincronización de Inventario (MULTI-SUCURSAL OPTIMIZADA)
if mappings:
# Traemos todos los niveles de una vez para ser precisos
url_levels = f"https://{shop_domain}/admin/api/2024-01/inventory_levels.json?inventory_item_ids={inv_item_id}"
resp_levels = requests.get(url_levels, headers=headers, timeout=15)
stock_info = []
if resp_levels.status_code == 200:
levels = resp_levels.json().get('inventory_levels', [])
# Crear mapa de Shopify Location ID -> Qty
shopify_stock_map = {str(l['location_id']): l.get('available', 0) for l in levels}
for mapping in mappings:
if not mapping.shopify_location_id or not mapping.odoo_location_id:
continue
loc_id_str = str(mapping.shopify_location_id)
qty = shopify_stock_map.get(loc_id_str, 0)
# Registrar para el campo informativo
stock_info.append(f"{mapping.shopify_location_name or loc_id_str}: {qty}")
if qty > 0:
self.env['stock.quant'].with_context(inventory_mode=True).create({
'product_id': odoo_variant.id,
'location_id': mapping.odoo_location_id.id,
'inventory_quantity': qty,
}).action_apply_inventory()
total_qty += qty
self.shopify_stock_info = " | ".join(stock_info) if stock_info else "Sin stock en sucursales mapeadas"
# 5. Descarga de Imagen
if self.product_image:
try:
img_resp = requests.get(self.product_image, timeout=10)
if img_resp.status_code == 200:
odoo_variant.product_tmpl_id.write({
'image_1920': base64.b64encode(img_resp.content)
})
except:
pass
self.write({
'is_imported': True,
'shopify_inventory_item_id': str(inv_item_id)
})
@api.depends('product_image')
def _compute_image_html(self):
for record in self:
if record.product_image:
record.image_html = f'<img src="{record.product_image}" style="max-height: 50px; max-width: 50px; border-radius: 4px;"/>'
else:
record.image_html = False
def action_refresh_shopify_stock_info(self):
"""Consulta el stock en Shopify y actualiza solo el campo informativo, sin tocar Odoo."""
self.ensure_one()
config = self.config_id
shop_domain = config.shop_url
token = config._ensure_access_token()
headers = {'X-Shopify-Access-Token': token}
# Asegurar ID de inventario
inv_item_id = self.shopify_inventory_item_id
if not inv_item_id or inv_item_id == '0':
url_v = f"https://{shop_domain}/admin/api/2024-01/variants/{self.shopify_variant_id}.json"
resp_v = requests.get(url_v, headers=headers, timeout=10)
if resp_v.status_code == 200:
inv_item_id = str(resp_v.json().get('variant', {}).get('inventory_item_id', '0'))
self.shopify_inventory_item_id = inv_item_id
if not inv_item_id or inv_item_id == '0':
raise UserError("No se pudo obtener el ID de inventario de Shopify.")
url_levels = f"https://{shop_domain}/admin/api/2024-01/inventory_levels.json?inventory_item_ids={inv_item_id}"
resp_levels = requests.get(url_levels, headers=headers, timeout=15)
if resp_levels.status_code == 200:
levels = resp_levels.json().get('inventory_levels', [])
shopify_stock_map = {str(l['location_id']): l.get('available', 0) for l in levels}
html = """<table class="table table-sm" style="width:100%; font-size: 13px;">
<thead><tr style="background-color: #e9ecef;"><th>Sucursal Shopify</th><th class="text-right">Stock</th></tr></thead>
<tbody>"""
mappings = config.location_mapping_ids
for mapping in mappings:
if not mapping.shopify_location_id: continue
qty = shopify_stock_map.get(str(mapping.shopify_location_id), 0)
color = "#17a2b8" if qty > 0 else "#6c757d"
html += f'<tr><td>{mapping.shopify_location_name}</td><td class="text-right"><span class="badge badge-pill" style="background-color: {color}; color: white; padding: 5px 10px;">{qty}</span></td></tr>'
html += "</tbody></table>"
self.shopify_stock_info = html
self.shopify_qty_total = sum(shopify_stock_map.values())
self.sync_history = f"✅ CONSULTA EXITOSA [{fields.Datetime.now()}] - Se obtuvo el stock real de Shopify."
else:
self.sync_history = f"❌ ERROR DE CONSULTA [{fields.Datetime.now()}] - {resp_levels.text}"
raise UserError(f"Error: {resp_levels.text}")
def action_sync_inventory_only(self):
"""Actualiza el inventario en Odoo basándose en lo que hay en Shopify actualmente."""
self.ensure_one()
if not self.exists_in_odoo:
raise UserError("Este producto no está en Odoo. Usa el botón 'Importar' primero.")
config = self.config_id
shop_domain = config.shop_url
token = config._ensure_access_token()
headers = {'X-Shopify-Access-Token': token}
# 1. Asegurar que tenemos el inventory_item_id
inv_item_id = self.shopify_inventory_item_id
if not inv_item_id or inv_item_id == '0':
url_v = f"https://{shop_domain}/admin/api/2024-01/variants/{self.shopify_variant_id}.json"
resp_v = requests.get(url_v, headers=headers, timeout=10)
if resp_v.status_code == 200:
inv_item_id = str(resp_v.json().get('variant', {}).get('inventory_item_id', '0'))
self.shopify_inventory_item_id = inv_item_id
if not inv_item_id or inv_item_id == '0':
raise UserError("No se pudo obtener el ID de inventario de Shopify.")
# 2. Buscar el producto en Odoo
product = self.env['product.product'].search([('default_code', '=', self.old_sku)], limit=1)
if not product:
raise UserError(f"No se encontró el producto con SKU {self.old_sku} en Odoo.")
# 3. Consultar Shopify (Multi-sucursal)
url_levels = f"https://{shop_domain}/admin/api/2024-01/inventory_levels.json?inventory_item_ids={inv_item_id}"
resp_levels = requests.get(url_levels, headers=headers, timeout=15)
if resp_levels.status_code == 200:
levels = resp_levels.json().get('inventory_levels', [])
shopify_stock_map = {str(l['location_id']): l.get('available', 0) for l in levels}
html = """<table class="table table-sm" style="width:100%; font-size: 13px;">
<thead><tr style="background-color: #e9ecef;"><th>Sucursal Shopify</th><th class="text-right">Stock</th></tr></thead>
<tbody>"""
mappings = config.location_mapping_ids
for mapping in mappings:
if not mapping.shopify_location_id or not mapping.odoo_location_id:
continue
qty = shopify_stock_map.get(str(mapping.shopify_location_id), 0)
# Actualizar Odoo: Ponemos la cantidad exacta (Inventory Adjustment)
self.env['stock.quant'].with_context(inventory_mode=True).create({
'product_id': product.id,
'location_id': mapping.odoo_location_id.id,
'inventory_quantity': qty,
}).action_apply_inventory()
color = "#17a2b8" if qty > 0 else "#6c757d"
html += f'<tr><td>{mapping.shopify_location_name}</td><td class="text-right"><span class="badge badge-pill" style="background-color: {color}; color: white; padding: 5px 10px;">{qty}</span></td></tr>'
html += "</tbody></table>"
self.shopify_stock_info = html
self.shopify_qty_total = sum(shopify_stock_map.values())
self.last_sync_date = fields.Datetime.now()
self.sync_history = f"🚀 ÉXITO [{fields.Datetime.now()}] - Inventario sincronizado correctamente en todos los almacenes mapeados."
return {
'effect': {
'fadeout': 'slow',
'message': 'Inventario sincronizado y actualizado ✓',
'type': 'rainbow_man',
}
}
else:
error_msg = f"❌ ERROR DE SYNC [{fields.Datetime.now()}] - {resp_levels.text}"
self.sync_history = error_msg
raise UserError(f"Error consultando Shopify: {resp_levels.text}")
def _get_shopify_total_qty(self):
"""Helper para obtener el stock total de Shopify considerando mapeos."""
self.ensure_one()
config = self.config_id
shop_domain = config.shop_url
headers = {'X-Shopify-Access-Token': config.access_token}
inv_item_id = self.shopify_inventory_item_id
# SI NO TENEMOS EL ID, LO BUSCAMOS EN SHOPIFY AL VUELO
if not inv_item_id or inv_item_id == '0':
url_v = f"https://{shop_domain}/admin/api/2024-01/variants/{self.shopify_variant_id}.json"
resp_v = requests.get(url_v, headers=headers, timeout=10)
if resp_v.status_code == 200:
variant_data = resp_v.json().get('variant', {})
inv_item_id = str(variant_data.get('inventory_item_id', '0'))
if inv_item_id != '0':
self.write({'shopify_inventory_item_id': inv_item_id})
if not inv_item_id or inv_item_id == '0':
return 0
total = 0
mappings = config.location_mapping_ids
if not mappings:
return 0
# Creamos una lista de los IDs de Shopify que nos interesan
interested_location_ids = [str(m.shopify_location_id) for m in mappings if m.shopify_location_id]
try:
# Consultamos TODOS los niveles del item de una sola vez
url = f"https://{shop_domain}/admin/api/2024-01/inventory_levels.json?inventory_item_ids={inv_item_id}"
resp = requests.get(url, headers=headers, timeout=12)
# MANEJO DE RATE LIMIT: Si vamos muy rápido, esperamos y reintentamos una vez
if resp.status_code == 429:
_logger.warning("Rate limit alcanzado en auditoría masiva. Esperando 3 segundos...")
time.sleep(3)
resp = requests.get(url, headers=headers, timeout=12)
if resp.status_code == 200:
levels = resp.json().get('inventory_levels', [])
found_any = False
for level in levels:
loc_id = str(level.get('location_id'))
if loc_id in interested_location_ids:
total += level.get('available', 0)
found_any = True
# Si no encontramos ninguna ubicación de las interesadas, pero la respuesta fue exitosa,
# devolvemos 0 (aquí sí es un 0 real).
return total
else:
_logger.error("Shopify API Error %s en SKU %s: %s", resp.status_code, self.old_sku, resp.text)
# IMPORTANTE: No retornamos 0 si hubo un error de API, retornamos None o levantamos error
# para que el hilo de auditoría sepa que NO fue un 0 real.
return -999999
except Exception as e:
_logger.error("Excepción consultando stock en SKU %s: %s", self.old_sku, str(e))
return -999999
@@ -0,0 +1,41 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data noupdate="1">
<record id="category_hazard" model="ir.module.category">
<field name="name">Hazard / HRSGOLF</field>
<field name="sequence">100</field>
</record>
<!-- 1. DISEÑO: Crea productos y sube fotos. Sin acceso a precios ni Shopify. -->
<record id="group_hazard_designer" model="res.groups">
<field name="name">Diseño (Crear Productos y Fotos)</field>
<field name="category_id" ref="category_hazard"/>
<field name="implied_ids" eval="[(4, ref('base.group_user'))]"/>
<field name="comment">Puede crear productos y subir fotos de catálogo. No tiene acceso a precios ni a la configuración de Shopify.</field>
</record>
<!-- 2. BODEGA: Gestiona stock y tracking. Sin acceso a precios. -->
<record id="group_hazard_warehouse" model="res.groups">
<field name="name">Bodega (Stock y Tracking)</field>
<field name="category_id" ref="category_hazard"/>
<field name="implied_ids" eval="[(4, ref('stock.group_stock_user'))]"/>
<field name="comment">Gestiona existencias de stock y registra guías de despacho. No tiene acceso a precios ni a la configuración de Shopify.</field>
</record>
<!-- 3. VENTAS B2B: Hace ventas y ve precios. Sin acceso a configuración de Shopify. -->
<record id="group_hazard_sales" model="res.groups">
<field name="name">Ventas B2B (Solo Lectura Precios)</field>
<field name="category_id" ref="category_hazard"/>
<field name="implied_ids" eval="[(4, ref('sales_team.group_sale_salesman'))]"/>
<field name="comment">Realiza cotizaciones y pedidos B2B. Puede consultar precios pero no modificarlos, ni editar la configuración de Shopify.</field>
</record>
<!-- 4. ADMINISTRACIÓN Y FINANZAS: Control total de catálogo, precios y configuración de Shopify. -->
<record id="group_hazard_finance" model="res.groups">
<field name="name">Administración y Finanzas (Precios, Catálogo y Shopify)</field>
<field name="category_id" ref="category_hazard"/>
<field name="implied_ids" eval="[(4, ref('sales_team.group_sale_salesman_all_leads'))]"/>
<field name="comment">Control total sobre productos, precios, SKUs, códigos de barras, y la configuración de Shopify.</field>
</record>
</data>
</odoo>
@@ -0,0 +1,127 @@
/*
HAZARD - Estilos Visuales Premium (Negro Grafito / Carbón Suave)
Este archivo es puramente cosmético. Modifica los colores del navbar backend
y el fondo de pantalla de inicio (App Switcher) de Odoo Enterprise.
*/
/* 1. Estilo General de la Barra de Navegación (Negro Grafito / Carbón Suave) */
.o_main_navbar {
/* Color negro carbón elegante, no tan oscuro (con un toque de gris) */
background: #1a1a1c !important;
background-color: #1a1a1c !important;
background-image: none !important;
border-bottom: 2px solid #2d2d30 !important; /* Línea de división gris grafito */
height: 46px !important;
}
/* 2. Inyección del Nombre de la Marca (Solo "HAZARD" en color blanco) */
.o_main_navbar::before {
content: "HAZARD" !important;
color: #ffffff !important;
font-weight: 800 !important;
font-size: 14px !important;
letter-spacing: 2.5px !important;
display: flex !important;
align-items: center !important;
padding-left: 20px !important;
padding-right: 20px !important;
height: 100% !important;
border-right: 1px solid #2d2d30 !important;
margin-right: 10px !important;
font-family: sans-serif !important;
}
/* 3. Textos y Enlaces del Navbar Superior (Solo el menú de primer nivel) */
.o_main_navbar > a,
.o_main_navbar > button,
.o_main_navbar .o_menu_brand,
.o_main_navbar .o_nav_entry {
color: #ffffff !important;
font-weight: 500 !important;
}
/* 4. Efecto Hover de Menús Superiores (Fondo gris oscuro al pasar el mouse) */
.o_main_navbar > a:hover,
.o_main_navbar > button:hover,
.o_main_navbar .show,
.o_main_navbar .active {
background-color: #2d2d30 !important;
background: #2d2d30 !important;
color: #ffffff !important;
}
/* 5. El botón del menú de aplicaciones (Home Menu grid) */
.o_main_navbar .o_menu_toggle {
background-color: transparent !important;
color: #ffffff !important;
border-right: 1px solid #2d2d30 !important;
}
.o_main_navbar .o_menu_toggle:hover {
background-color: #2d2d30 !important;
background: #2d2d30 !important;
}
/* =========================================================================
6. CORRECCIÓN DE LEGIBILIDAD EN MENÚS DESPLEGABLES (DROPDOWNS)
========================================================================= */
/* Forzar que el fondo del dropdown sea blanco y limpio */
.o_main_navbar .dropdown-menu {
background-color: #ffffff !important;
border: 1px solid #cccccc !important;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15) !important;
padding: 8px 0 !important;
}
/* Encabezados de sección ("Gestión de almacén", "Productos", etc.) */
.o_main_navbar .dropdown-menu .dropdown-header,
.o_main_navbar .dropdown-menu h6,
.o_main_navbar .dropdown-menu .dropdown-item-text {
color: #555555 !important;
font-weight: 700 !important;
font-size: 11px !important;
text-transform: uppercase !important;
letter-spacing: 0.8px !important;
padding-top: 10px !important;
padding-bottom: 5px !important;
}
/* Elementos clicables del submenú (Los enlaces de las opciones) */
.o_main_navbar .dropdown-menu a,
.o_main_navbar .dropdown-menu .dropdown-item,
.o_main_navbar .dropdown-menu button {
color: #212529 !important;
background-color: transparent !important;
background: transparent !important;
font-weight: 400 !important;
font-size: 13px !important;
padding: 6px 20px !important;
display: block !important;
width: 100% !important;
text-align: left !important;
}
/* Efecto hover al pasar el mouse por encima de una opción del submenú */
.o_main_navbar .dropdown-menu a:hover,
.o_main_navbar .dropdown-menu .dropdown-item:hover,
.o_main_navbar .dropdown-menu button:hover {
background-color: #f1f3f5 !important;
background: #f1f3f5 !important;
color: #000000 !important;
font-weight: 500 !important;
}
/* =========================================================================
7. PERSONALIZACIÓN DEL FONDO DEL MENÚ DE INICIO (APP SWITCHER)
Carga la imagen premium del cliente con una capa oscura de contraste
========================================================================= */
.o_home_menu {
/* Mezcla de capa oscura translúcida del 50% con la imagen de Hazard copiada en local */
background: linear-gradient(rgba(0, 0, 0, 0.45), rgba(0, 0, 0, 0.55)),
url('/hazard_shopify/static/src/img/hazard_bg.png') no-repeat center center fixed !important;
background-image: linear-gradient(rgba(0, 0, 0, 0.45), rgba(0, 0, 0, 0.55)),
url('/hazard_shopify/static/src/img/hazard_bg.png') !important;
background-size: cover !important;
background-position: center center !important;
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 824 KiB

@@ -0,0 +1,56 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data>
<record id="report_product_template_label_thermal2x1" model="ir.actions.report">
<field name="name">Product Label (Thermal 2x1)</field>
<field name="model">product.template</field>
<field name="report_type">qweb-pdf</field>
<field name="report_name">hazard_shopify.report_producttemplatelabel_thermal2x1</field>
<field name="report_file">hazard_shopify.report_producttemplatelabel_thermal2x1</field>
<field name="paperformat_id" ref="hazard_shopify.paperformat_label_thermal2x1"/>
<field name="print_report_name">'Products Labels - %s' % (object.name)</field>
<field name="binding_type">report</field>
</record>
<template id="report_producttemplatelabel_thermal2x1">
<t t-call="web.html_container">
<t t-set="barcode_size" t-value="'width: 40mm; height: 8mm;'"/>
<t t-foreach="quantity.items()" t-as="barcode_and_qty_by_product">
<t t-set="product" t-value="barcode_and_qty_by_product[0]"/>
<t t-foreach="barcode_and_qty_by_product[1]" t-as="barcode_and_qty">
<t t-set="barcode" t-value="barcode_and_qty[0]"/>
<t t-foreach="range(barcode_and_qty[1])" t-as="qty">
<div class="page" style="position: relative; width: 51mm; height: 25mm; background-color: white; overflow: hidden; box-sizing: border-box; page-break-inside: avoid; page-break-after: always;">
<!-- Top Text Section -->
<div style="position: absolute; top: 1mm; left: 0; width: 100%; text-align: center;">
<div style="font-family: Arial, sans-serif; font-size: 10px; font-weight: normal; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 100%; padding: 0 1mm;" t-esc="product.name"/>
<t t-if="product.is_product_variant and product.product_template_attribute_value_ids">
<div style="font-family: Arial, sans-serif; font-size: 11px; font-weight: normal; margin-top: 1px;" t-esc="', '.join(product.product_template_attribute_value_ids.mapped('name'))"/>
</t>
</div>
<!-- Bottom Barcode Section -->
<div style="position: absolute; bottom: 1.5mm; left: 0; width: 100%; text-align: center;">
<t t-if="barcode">
<div t-out="barcode" style="padding:0; margin: 0 auto;" t-options="{'widget': 'barcode', 'symbology': 'auto', 'img_style': 'width: 44mm; height: 11mm; display: block; margin: 0 auto;'}"/>
<div style="font-family: Arial, sans-serif; font-size: 11px; font-weight: normal; margin-top: 1px; line-height: 1;" t-esc="barcode"/>
</t>
<t t-elif="product.default_code">
<div t-out="product.default_code" style="padding:0; margin: 0 auto;" t-options="{'widget': 'barcode', 'symbology': 'auto', 'img_style': 'width: 44mm; height: 11mm; display: block; margin: 0 auto;'}"/>
<div style="font-family: Arial, sans-serif; font-size: 11px; font-weight: normal; margin-top: 1px; line-height: 1;" t-esc="product.default_code"/>
</t>
<t t-else="">
<div style="font-family: Arial, sans-serif; font-size: 11px; color: grey;">Sin código</div>
</t>
</div>
</div>
</t>
</t>
</t>
</t>
</template>
</data>
</odoo>
@@ -0,0 +1,187 @@
<?xml version="1.0" encoding="UTF-8"?>
<odoo>
<!-- ===================================================================
EXTENSIÓN: Vista de Producto — Integración Shopify
Agrega panel de publicación en Shopify y controles de sincronización.
=================================================================== -->
<record id="product_template_hazard_shopify_view" model="ir.ui.view">
<field name="name">product.template.hazard.shopify.inherit</field>
<field name="model">product.template</field>
<field name="inherit_id" ref="product.product_template_only_form_view"/>
<field name="arch" type="xml">
<!-- ── Botón de estado Shopify en el header (stat button) ── -->
<xpath expr="//div[@name='button_box']" position="inside">
<button name="action_push_to_shopify"
type="object"
class="oe_stat_button"
icon="fa-shopping-bag"
groups="hazard_shopify.group_hazard_designer,hazard_shopify.group_hazard_finance"
attrs="{'invisible': [('shopify_published', '=', True)]}"
help="Publicar este producto en Shopify">
<div class="o_field_widget o_stat_info">
<span class="o_stat_value" style="font-size:12px;">Publicar</span>
<span class="o_stat_text">Shopify</span>
</div>
</button>
<button name="action_push_to_shopify"
type="object"
class="oe_stat_button"
icon="fa-check-circle"
groups="hazard_shopify.group_hazard_designer,hazard_shopify.group_hazard_finance"
attrs="{'invisible': [('shopify_published', '=', False)]}"
help="Sincronizar cambios con Shopify">
<div class="o_field_widget o_stat_info">
<span class="o_stat_value">
<field name="shopify_sync_status"
widget="badge"
decoration-success="shopify_sync_status == 'synced'"
decoration-warning="shopify_sync_status == 'pending'"
decoration-danger="shopify_sync_status == 'error'"/>
</span>
<span class="o_stat_text">Shopify</span>
</div>
</button>
</xpath>
<!-- ── Panel de Shopify en pestaña "Información general" ── -->
<xpath expr="//page[@name='general_information']" position="after">
<page string="🛒 Shopify"
name="shopify_info"
groups="hazard_shopify.group_hazard_designer,hazard_shopify.group_hazard_finance">
<group>
<group string="Estado de Publicación">
<field name="shopify_published"
string="¿Publicado en Shopify?"
widget="toggle_button"
readonly="1"/>
<field name="shopify_status"
string="Estado en Shopify"
widget="selection"/>
<field name="shopify_sync_status"
string="Estado de sincronización"
readonly="1"/>
<field name="shopify_synced_at"
string="Primera sincronización"
readonly="1"/>
<field name="shopify_last_pushed_at"
string="Última actualización a Shopify"
readonly="1"/>
</group>
<group string="Información Shopify">
<field name="shopify_config_id"
string="Tienda Shopify"
domain="[('state','=','confirmed')]"
options="{'no_create': True}"/>
<field name="shopify_id"
string="ID Producto Shopify"
readonly="1"
attrs="{'invisible': [('shopify_id', '=', False)]}"/>
</group>
</group>
<!-- Canales de Venta — ancho completo -->
<separator string="Canales de Venta Shopify"
attrs="{'invisible': [('shopify_published', '=', False)]}"/>
<div class="mb-2" attrs="{'invisible': [('shopify_published', '=', False)]}">
<button name="action_refresh_shopify_channels"
type="object"
string="🔄 Leer canales desde Shopify"
class="btn btn-sm btn-info"
groups="hazard_shopify.group_hazard_designer,hazard_shopify.group_hazard_finance"
help="Actualiza esta lista leyendo el estado real desde Shopify sin realizar cambios."/>
</div>
<field name="shopify_publication_ids"
nolabel="1"
attrs="{'invisible': [('shopify_published', '=', False)]}">
<tree editable="bottom" create="false" delete="false">
<field name="publication_name" string="Canal de Venta" readonly="1"/>
<field name="is_published" string="Publicado" widget="boolean_toggle"/>
<field name="publication_id" invisible="1"/>
</tree>
</field>
<!-- Error de sync -->
<group attrs="{'invisible': [('shopify_sync_error', '=', False)]}">
<div class="alert alert-danger" role="alert" style="margin:8px 0;">
<strong>⚠️ Último error de sincronización:</strong>
<field name="shopify_sync_error" readonly="1" nolabel="1"/>
</div>
</group>
<!-- Botones de acción manuales -->
<div class="o_field_widget mt-3">
<button name="action_push_to_shopify"
type="object"
string="📤 Publicar / Actualizar en Shopify"
class="btn btn-primary me-2"
groups="hazard_shopify.group_hazard_designer,hazard_shopify.group_hazard_finance"/>
<button name="action_unpublish_from_shopify"
type="object"
string="🚫 Despublicar de Shopify"
class="btn btn-secondary"
attrs="{'invisible': [('shopify_published', '=', False)]}"
groups="hazard_shopify.group_hazard_finance"
confirm="¿Seguro que quieres ocultar este producto de la tienda Shopify? No se eliminará, solo quedará como borrador."/>
</div>
<!-- Instrucciones para el equipo de diseño -->
<div class="alert alert-info mt-3" role="alert"
attrs="{'invisible': [('shopify_published', '=', True)]}">
<strong>️ ¿Cómo publicar este producto?</strong>
<ol style="margin-top:6px; margin-bottom:0;">
<li>Completa el nombre, descripción, precio y variantes del producto.</li>
<li>Sube la imagen principal del producto.</li>
<li>Presiona el botón <strong>"📤 Publicar / Actualizar en Shopify"</strong>.</li>
<li>El producto aparecerá automáticamente en la tienda de Shopify.</li>
</ol>
</div>
</page>
</xpath>
<!-- ── Restricciones de precios (ya existían, las mantenemos) ── -->
<xpath expr="//field[@name='list_price']" position="attributes">
<attribute name="groups">hazard_shopify.group_hazard_sales,hazard_shopify.group_hazard_finance</attribute>
</xpath>
<xpath expr="//field[@name='standard_price']" position="attributes">
<attribute name="groups">hazard_shopify.group_hazard_sales,hazard_shopify.group_hazard_finance</attribute>
</xpath>
<xpath expr="//field[@name='taxes_id']" position="attributes">
<attribute name="groups">hazard_shopify.group_hazard_sales,hazard_shopify.group_hazard_finance</attribute>
</xpath>
<xpath expr="//field[@name='supplier_taxes_id']" position="attributes">
<attribute name="groups">hazard_shopify.group_hazard_sales,hazard_shopify.group_hazard_finance</attribute>
</xpath>
<xpath expr="//field[@name='default_code']" position="attributes">
<attribute name="groups">hazard_shopify.group_hazard_designer,hazard_shopify.group_hazard_sales,hazard_shopify.group_hazard_finance</attribute>
</xpath>
<xpath expr="//field[@name='barcode']" position="attributes">
<attribute name="groups">hazard_shopify.group_hazard_designer,hazard_shopify.group_hazard_sales,hazard_shopify.group_hazard_finance</attribute>
</xpath>
<xpath expr="//field[@name='barcode']" position="after">
<button name="action_generate_barcode" string="Generar EAN-13" type="object" class="btn btn-sm btn-link" icon="fa-barcode" attrs="{'invisible': [('barcode', '!=', False)]}" groups="hazard_shopify.group_hazard_designer,hazard_shopify.group_hazard_sales,hazard_shopify.group_hazard_finance"/>
</xpath>
</field>
</record>
<!-- EXTENSIÓN: Vista de Variante de Producto — Restricción de Precios para Bodega/Diseñador -->
<record id="product_product_hazard_shopify_view" model="ir.ui.view">
<field name="name">product.product.hazard.shopify.inherit</field>
<field name="model">product.product</field>
<field name="inherit_id" ref="product.product_normal_form_view"/>
<field name="arch" type="xml">
<xpath expr="//field[@name='lst_price']" position="attributes">
<attribute name="groups">hazard_shopify.group_hazard_sales,hazard_shopify.group_hazard_finance</attribute>
</xpath>
<xpath expr="//field[@name='standard_price']" position="attributes">
<attribute name="groups">hazard_shopify.group_hazard_sales,hazard_shopify.group_hazard_finance</attribute>
</xpath>
<xpath expr="//field[@name='barcode']" position="after">
<button name="action_generate_barcode" string="Generar EAN-13" type="object" class="btn btn-sm btn-link" icon="fa-barcode" attrs="{'invisible': [('barcode', '!=', False)]}" groups="hazard_shopify.group_hazard_designer,hazard_shopify.group_hazard_sales,hazard_shopify.group_hazard_finance"/>
</xpath>
</field>
</record>
</odoo>
@@ -0,0 +1,226 @@
<?xml version="1.0" encoding="UTF-8"?>
<odoo>
<!-- ===================================================================
EXTENSIÓN: Vista de Pedido de Venta — Integración Shopify
Agrega botón de fulfillment e indicadores de estado en sale.order
=================================================================== -->
<record id="sale_order_shopify_fulfillment_view" model="ir.ui.view">
<field name="name">sale.order.shopify.fulfillment.inherit</field>
<field name="model">sale.order</field>
<field name="inherit_id" ref="sale.view_order_form"/>
<field name="arch" type="xml">
<!-- ── Botón de fulfillment en el header del pedido ── -->
<xpath expr="//div[@name='button_box']" position="inside">
<button name="action_notify_shopify_fulfillment"
type="object"
class="oe_stat_button"
icon="fa-truck"
groups="hazard_shopify.group_hazard_warehouse,hazard_shopify.group_hazard_sales,hazard_shopify.group_hazard_finance"
attrs="{'invisible': [('shopify_order_id', '=', False)]}"
help="Notificar a Shopify que este pedido ha sido enviado">
<div class="o_field_widget o_stat_info">
<span class="o_stat_value">
<field name="shopify_fulfillment_status"
widget="badge"
decoration-success="shopify_fulfillment_status == 'fulfilled'"
decoration-warning="shopify_fulfillment_status == 'partial'"
decoration-danger="shopify_fulfillment_status == 'error'"
decoration-muted="not shopify_fulfillment_status"/>
</span>
<span class="o_stat_text">Shopify</span>
</div>
</button>
<button name="action_view_shopify_refunds"
type="object"
class="oe_stat_button"
icon="fa-undo"
groups="hazard_shopify.group_hazard_warehouse,hazard_shopify.group_hazard_sales,hazard_shopify.group_hazard_finance"
attrs="{'invisible': ['|', ('shopify_order_id', '=', False), ('shopify_refund_status', '=', 'no_refund')]}"
help="Estado de Reembolsos de Shopify">
<div class="o_field_widget o_stat_info">
<span class="o_stat_value">
<field name="shopify_refund_status"
widget="badge"
decoration-danger="shopify_refund_status == 'refunded'"
decoration-warning="shopify_refund_status == 'partially_refunded'"/>
</span>
<span class="o_stat_text">Reembolso</span>
</div>
</button>
</xpath>
<!-- ── Campos de Shopify en pestaña "Otra información" ── -->
<xpath expr="//page[@name='other_information']" position="inside">
<group string="🛒 Shopify"
groups="hazard_shopify.group_hazard_warehouse,hazard_shopify.group_hazard_sales,hazard_shopify.group_hazard_finance"
attrs="{'invisible': [('shopify_order_id', '=', False)]}">
<group>
<field name="shopify_order_id" readonly="1" string="ID Orden Shopify"/>
<field name="shopify_order_number" readonly="1" string="# Orden Shopify"/>
</group>
<group>
<field name="shopify_fulfillment_status" readonly="1" string="Estado Fulfillment"/>
<field name="shopify_fulfillment_id" readonly="1" string="ID Fulfillment Shopify"
attrs="{'invisible': [('shopify_fulfillment_id', '=', False)]}"/>
</group>
<!-- ── Campos de reembolsos y devoluciones ── -->
<group string="🔄 Reembolsos y Devoluciones" col="2">
<field name="shopify_financial_status" readonly="1" string="Estado Financiero"/>
<field name="shopify_refund_status" readonly="1" string="Estado Reembolso"/>
<field name="shopify_refund_amount" readonly="1" string="Monto Reembolsado" widget="monetary"/>
<field name="shopify_refund_ids" readonly="1" string="IDs Reembolsos" attrs="{'invisible': [('shopify_refund_ids', '=', False)]}"/>
</group>
<!-- ── Campos de guía de envío ── -->
<group string="📦 Datos de Envío" col="2">
<field name="shopify_shipping_title" readonly="1" string="Método Envío Shopify"/>
<field name="shopify_carrier_name"
string="Paquetería"
attrs="{'readonly': [('shopify_fulfillment_status', '=', 'fulfilled')]}"/>
<field name="shopify_tracking_number"
string="Número de Guía"
placeholder="Ej: 1234567890"
attrs="{'readonly': [('shopify_fulfillment_status', '=', 'fulfilled')]}"/>
</group>
<div class="col-12 mt-1" attrs="{'invisible': [('shopify_fulfillment_status', '!=', False)]}">
<button name="action_notify_shopify_fulfillment"
type="object"
string="📦 Marcar como Enviado en Shopify"
class="btn btn-primary"
groups="hazard_shopify.group_hazard_warehouse,hazard_shopify.group_hazard_sales,hazard_shopify.group_hazard_finance"
attrs="{'invisible': [('shopify_order_id', '=', False)]}"/>
<span class="text-muted ml-2" style="font-size:0.85em;">
Solo disponible cuando haya entregas validadas en Odoo.
</span>
</div>
</group>
</xpath>
</field>
</record>
<!-- ===================================================================
EXTENSIÓN: Vista de Árbol/Lista de Pedidos y Cotizaciones
Agrega la columna con el número de pedido de Shopify directamente
en la tabla principal.
=================================================================== -->
<record id="view_quotation_tree_shopify" model="ir.ui.view">
<field name="name">sale.quotation.tree.shopify.inherit</field>
<field name="model">sale.order</field>
<field name="inherit_id" ref="sale.view_quotation_tree"/>
<field name="arch" type="xml">
<xpath expr="//field[@name='name']" position="after">
<field name="shopify_order_number" string="# Shopify" optional="show"/>
<field name="shopify_financial_status" string="Pago (Shopify)" optional="show" widget="badge"
decoration-success="shopify_financial_status == 'paid'"
decoration-warning="shopify_financial_status in ('pending', 'partially_paid')"
decoration-danger="shopify_financial_status in ('refunded', 'voided')"/>
<field name="shopify_refund_status" string="Reembolso (Shopify)" optional="show" widget="badge"
decoration-danger="shopify_refund_status == 'refunded'"
decoration-warning="shopify_refund_status == 'partially_refunded'"
decoration-muted="shopify_refund_status in ('no_refund', False)"/>
<field name="shopify_fulfillment_status" string="Envío (Shopify)" optional="show" widget="badge"
decoration-success="shopify_fulfillment_status == 'fulfilled'"
decoration-warning="shopify_fulfillment_status == 'partial'"
decoration-info="shopify_fulfillment_status == 'pending'"/>
</xpath>
</field>
</record>
<record id="view_order_tree_shopify" model="ir.ui.view">
<field name="name">sale.order.tree.shopify.inherit</field>
<field name="model">sale.order</field>
<field name="inherit_id" ref="sale.view_order_tree"/>
<field name="arch" type="xml">
<xpath expr="//field[@name='name']" position="after">
<field name="shopify_order_number" string="# Shopify" optional="show"/>
<field name="shopify_financial_status" string="Pago (Shopify)" optional="show" widget="badge"
decoration-success="shopify_financial_status == 'paid'"
decoration-warning="shopify_financial_status in ('pending', 'partially_paid')"
decoration-danger="shopify_financial_status in ('refunded', 'voided')"/>
<field name="shopify_refund_status" string="Reembolso (Shopify)" optional="show" widget="badge"
decoration-danger="shopify_refund_status == 'refunded'"
decoration-warning="shopify_refund_status == 'partially_refunded'"
decoration-muted="shopify_refund_status in ('no_refund', False)"/>
<field name="shopify_fulfillment_status" string="Envío (Shopify)" optional="show" widget="badge"
decoration-success="shopify_fulfillment_status == 'fulfilled'"
decoration-warning="shopify_fulfillment_status == 'partial'"
decoration-info="shopify_fulfillment_status == 'pending'"/>
</xpath>
</field>
</record>
<!-- ===================================================================
EXTENSIÓN: Vista de Árbol/Lista de Órdenes de Entrega (Stock Picking)
Agrega la columna con el número de pedido de Shopify directamente
en la tabla de albaranes de almacén.
=================================================================== -->
<record id="view_picking_tree_shopify" model="ir.ui.view">
<field name="name">stock.picking.tree.shopify.inherit</field>
<field name="model">stock.picking</field>
<field name="inherit_id" ref="stock.vpicktree"/>
<field name="arch" type="xml">
<xpath expr="//field[@name='origin']" position="after">
<field name="shopify_order_number" string="# Shopify" optional="show"/>
</xpath>
</field>
</record>
<!-- ===================================================================
EXTENSIÓN: Vista de Formulario de Albarán (Stock Picking)
Agrega los badges visuales muy obvios para Bodega sobre el tipo de envío.
=================================================================== -->
<record id="view_picking_form_shopify" model="ir.ui.view">
<field name="name">stock.picking.form.shopify.inherit</field>
<field name="model">stock.picking</field>
<field name="inherit_id" ref="stock.view_picking_form"/>
<field name="arch" type="xml">
<xpath expr="//header" position="inside">
<button name="action_shopify_ready_for_pickup"
string="Notificar Listo para Retiro"
type="object"
class="oe_highlight btn-primary"
attrs="{'invisible': ['|', '|', '|', ('shopify_order_number', '=', False), ('shopify_delivery_type', '!=', 'pickup'), ('state', '!=', 'assigned'), ('shopify_ready_for_pickup_notified', '=', True)]}"/>
</xpath>
<xpath expr="//header" position="after">
<!-- Banner de advertencia visual interactivo para Almacén -->
<div class="alert alert-info text-center o_form_header"
role="alert"
style="margin-bottom:0px; font-weight:bold; font-size:1.1em; padding:8px 5px;"
attrs="{'invisible': ['|', ('shopify_order_number', '=', False), ('shopify_delivery_type', '!=', 'pickup')]}">
🔵 <strong>[ RETIRO EN OFICINA ]</strong> Este pedido de Shopify se entregará en mano. NO requiere Número de Guía.
</div>
<div class="alert alert-success text-center o_form_header"
role="alert"
style="margin-bottom:0px; font-weight:bold; font-size:1.1em; padding:8px 5px;"
attrs="{'invisible': ['|', ('shopify_order_number', '=', False), ('shopify_delivery_type', '!=', 'delivery')]}">
🚚 <strong>[ ENVÍO CON GUÍA ]</strong> Este pedido se envía por paquetería. <strong>Requiere Número de Guía obligatorio.</strong>
</div>
</xpath>
<xpath expr="//field[@name='partner_id']" position="after">
<field name="shopify_delivery_address"
string="Dirección Completa"
attrs="{'invisible': [('shopify_order_number', '=', False)]}"
readonly="1"/>
<field name="shopify_delivery_phone"
string="Teléfono"
attrs="{'invisible': ['|', ('shopify_order_number', '=', False), ('shopify_delivery_phone', '=', False)]}"
readonly="1"/>
<field name="shopify_delivery_mobile"
string="Celular"
attrs="{'invisible': ['|', ('shopify_order_number', '=', False), ('shopify_delivery_mobile', '=', False)]}"
readonly="1"/>
</xpath>
<xpath expr="//field[@name='origin']" position="after">
<field name="shopify_order_number" readonly="1" string="# Shopify" attrs="{'invisible': [('shopify_order_number', '=', False)]}"/>
<field name="shopify_shipping_title" readonly="1" string="Método Envío Shopify" attrs="{'invisible': [('shopify_order_number', '=', False)]}"/>
<field name="shopify_delivery_type" invisible="1"/>
<field name="shopify_ready_for_pickup_notified" invisible="1"/>
</xpath>
</field>
</record>
</odoo>
@@ -0,0 +1,808 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data>
<!-- Form View -->
<record id="view_shopify_config_form" model="ir.ui.view">
<field name="name">shopify.config.form</field>
<field name="model">shopify.config</field>
<field name="arch" type="xml">
<form string="Shopify">
<sheet>
<div class="oe_title">
<h1>
<field name="name" placeholder="Ej: Tienda Principal"/>
</h1>
</div>
<notebook>
<!-- 1. PANEL DE CONTROL (DASHBOARD) -->
<page string="📊 Panel de Control" name="dashboard_main">
<div class="o_horizontal_separator font-weight-bold mb-3" style="font-size: 1.15em; color: #1E3A8A;">🏷️ Resumen de SKUs en Catálogo</div>
<div class="row mb-4 mt-2 border-bottom pb-3">
<div class="col-3 text-center border-right">
<span class="text-muted d-block uppercase small">Total Variantes</span>
<h2 class="mt-0 font-weight-bold"><field name="total_count"/></h2>
</div>
<div class="col-3 text-center border-right text-info">
<span class="text-muted d-block uppercase small">Faltan por Sync</span>
<h2 class="mt-0 font-weight-bold"><field name="pending_count"/></h2>
</div>
<div class="col-3 text-center border-right text-success">
<span class="text-muted d-block uppercase small">Ya en Shopify</span>
<h2 class="mt-0 font-weight-bold"><field name="synced_count"/></h2>
</div>
<div class="col-3 text-center text-danger">
<span class="text-muted d-block uppercase small">Duplicados en Web</span>
<h2 class="mt-0 font-weight-bold"><field name="duplicate_count"/></h2>
</div>
</div>
<div class="o_horizontal_separator font-weight-bold mb-3" style="font-size: 1.15em; color: #0f766e;">🖼️ Estado de Catálogo e Imágenes</div>
<div class="row mb-3 border-bottom pb-3">
<div class="col-4 text-center border-right text-primary">
<span class="text-muted d-block uppercase small">Productos Importados</span>
<h2 class="mt-0 font-weight-bold"><field name="products_total"/></h2>
</div>
<div class="col-4 text-center border-right text-success">
<span class="text-muted d-block uppercase small">✅ Con Imagen</span>
<h2 class="mt-0 font-weight-bold"><field name="products_with_image"/></h2>
</div>
<div class="col-4 text-center text-warning">
<span class="text-muted d-block uppercase small">⏳ Sin Imagen</span>
<h2 class="mt-0 font-weight-bold"><field name="products_without_image"/></h2>
</div>
</div>
<!-- LIVE CONSOLE AND PROGRESS AREA -->
<div class="mb-4 mt-3">
<!-- Progress bar shown only when syncing -->
<div class="row mb-3" attrs="{'invisible': [('is_syncing', '=', False)]}">
<div class="col-12">
<div class="d-flex align-items-center justify-content-between mb-1 text-warning font-weight-bold" style="font-size: 1.1em;">
<span><i class="fa fa-spinner fa-spin mr-1"/> Sincronización en curso...</span>
<span><field name="sync_progress" readonly="1"/>%</span>
</div>
<field name="sync_progress" widget="progressbar" class="mt-1"/>
</div>
</div>
<!-- Monospace Console Log, ALWAYS visible in the middle -->
<div class="d-flex align-items-center justify-content-between mb-2">
<div class="o_horizontal_separator font-weight-bold m-0" style="font-size: 1.15em; color: #1E3A8A;">📝 Bitácora de Procesos (Consola de Operaciones)</div>
<button name="action_release_lock"
string="🔓 Liberar bloqueo"
type="object"
class="btn btn-outline-danger btn-sm py-0"
style="height: 24px; font-size: 11px;"
attrs="{'invisible': [('is_syncing', '=', False)]}"
confirm="¿Liberar el bloqueo de sincronización?"/>
</div>
<field name="sku_simulation_log" readonly="1" widget="text"
style="font-family: 'Courier New', Courier, monospace; font-size: 12px; background-color: #0F172A; color: #38BDF8; border: 1px solid #1E293B; border-radius: 8px; padding: 12px; min-height: 180px; max-height: 300px; overflow-y: auto;"
placeholder="Consola lista. Ejecuta cualquier simulación, stock audit o subida de códigos..."/>
</div>
<!-- GUÍA RÁPIDA HTML -->
<div class="alert alert-light mt-3 border" style="padding:15px; border-radius:8px; background:#F8FAFC; border:1px solid #E2E8F0;">
<h3 style="color:#0F172A; margin-top:0; font-weight:bold; margin-bottom:12px; font-size: 1.25em;">🗺️ Guía Rápida de Operación</h3>
<div class="row">
<div class="col-4">
<div style="background:#EFF6FF; border-left:4px solid #3B82F6; padding:12px; border-radius:4px; height:100%;">
<div style="font-weight:bold; color:#1E3A8A; font-size:13px; margin-bottom:6px;">🏷️ FASE 1: Limpieza de SKUs</div>
<p style="margin:0; font-size:11px; color:#334155; line-height: 1.4;">
1. Sube tu archivo CSV en la pestaña de <b>Conexión y API</b>.<br/>
2. Ve a <b>Asignar SKUs</b> y presiona <b>Simular SKUs</b> para revisar discrepancias.<br/>
3. Selecciona y haz clic en <b>Aplicar SKUs a Shopify</b>.
</p>
</div>
</div>
<div class="col-4">
<div style="background:#ECFDF5; border-left:4px solid #10B981; padding:12px; border-radius:4px; height:100%;">
<div style="font-weight:bold; color:#064E3B; font-size:13px; margin-bottom:6px;">📦 FASE 2: Catálogo y Stock</div>
<p style="margin:0; font-size:11px; color:#334155; line-height: 1.4;">
1. Ve a <b>Importar Catálogo</b> para descargar productos nuevos desde Shopify a Odoo.<br/>
2. Presiona <b>Marcar Discrepancias de Stock</b> para ver diferencias físicas.<br/>
3. Ajusta el stock usando <b>Actualizar Stock Seleccionado</b>.
</p>
</div>
</div>
<div class="col-4">
<div style="background:#FFFBEB; border-left:4px solid #F59E0B; padding:12px; border-radius:4px; height:100%;">
<div style="font-weight:bold; color:#78350F; font-size:13px; margin-bottom:6px;">🔖 FASE 3: Generación EAN-13</div>
<p style="margin:0; font-size:11px; color:#334155; line-height: 1.4;">
1. Ve a <b>Barcodes EAN-13</b>.<br/>
2. Haz clic en <b>1. Generar EAN-13</b> para folios nuevos sin colisiones.<br/>
3. Haz clic en <b>2. Subir a Shopify</b>.<br/>
4. Exporta tu archivo para Zebra/Dymo.
</p>
</div>
</div>
</div>
</div>
</page>
<!-- 2. SINCRONIZACIÓN MANUAL -->
<page string="🚀 Sincronización Manual" name="manual_sync">
<div style="background: #F8FAFC; padding: 20px; border-radius: 8px; border: 1px solid #E2E8F0; margin-bottom: 20px;">
<h3 style="color: #1E3A8A; margin-top: 0;">1. 📥 Sincronizar Catálogo (Productos y Variantes)</h3>
<p><strong>¿Qué hace?:</strong> Se conecta a Shopify y descarga todo tu catálogo. Si encuentra productos agrupados (ej. Playera con tallas S, M, L), construye perfectamente esa misma estructura de variantes dentro de Odoo.</p>
<p><strong>¿Cuándo usarlo?:</strong> Cuando creaste productos nuevos en Shopify y quieres traerlos a Odoo. <em>Nota: Esta descarga es pesada, es normal si la pantalla marca "conexión perdida", Odoo seguirá procesando en el fondo.</em></p>
<p><strong>¿Cuándo NO usarlo?:</strong> Si buscas descargar cantidades de inventario o fotografías, este botón no lo hace para que el proceso sea más rápido.</p>
<button name="action_sync_products"
string="Ejecutar: Sincronizar Catálogo"
type="object"
class="btn-primary mt-2"
confirm="¿Importar todos los productos de Shopify a Odoo? Esto puede tardar varios minutos si el catálogo es grande."/>
</div>
<div style="background: #F8FAFC; padding: 20px; border-radius: 8px; border: 1px solid #E2E8F0; margin-bottom: 20px;">
<h3 style="color: #065F46; margin-top: 0;">2. 🖼️ Descargar Imágenes</h3>
<p><strong>¿Qué hace?:</strong> Revisa qué productos en Odoo no tienen fotografía y las descarga directamente desde Shopify en segundo plano.</p>
<p><strong>¿Cuándo usarlo?:</strong> Después de haber sincronizado el catálogo (Paso 1). Como es un proceso en segundo plano, puedes darle clic y seguir trabajando en otras pantallas sin que Odoo se trabe.</p>
<button name="action_download_product_images"
string="Ejecutar: Descargar Imágenes"
type="object"
class="btn-info mt-2"
confirm="¿Descargar imágenes faltantes desde Shopify? El proceso se ejecutará silenciosamente de fondo."/>
</div>
<div style="background: #FDF2F8; padding: 20px; border-radius: 8px; border: 1px solid #FBCFE8; margin-bottom: 20px;">
<h3 style="color: #9D174D; margin-top: 0;">3. 📦 Importar Stock a Odoo (Shopify Manda)</h3>
<p><strong>¿Qué hace?:</strong> Descarga el inventario exacto que hay en cada sucursal de Shopify y <strong>aplasta</strong> las existencias de Odoo para que cuadren a la fuerza.</p>
<p><strong>¿Cuándo usarlo?:</strong> Durante la configuración inicial para traer tu stock de Shopify a Odoo por primera vez.</p>
<p style="color: #9D174D;"><strong>⚠️ ¿Cuándo NO usarlo?:</strong> ¡NUNCA lo uses en la operación diaria regular! Una vez que Odoo se convierta en tu "Fuente de Verdad" de inventario, Odoo debe mandar el stock a Shopify (usando las reglas), no al revés.</p>
<button name="action_import_inventory_from_shopify"
string="Ejecutar: Importar Stock a Odoo"
type="object"
class="btn-danger mt-2"
confirm="¡PRECAUCIÓN! Esto sobrescribirá las cantidades físicas de Odoo con lo que diga Shopify. ¿Estás seguro?"/>
</div>
</page>
<!-- 3. CONEXIÓN Y API -->
<page string="🔌 Conexión y API" name="connection_api">
<div class="row">
<div class="col-6">
<group string="🔑 Credenciales de Shopify">
<field name="shop_url" placeholder="tienda.myshopify.com"/>
<field name="api_key" string="Client ID" placeholder="803268f..."/>
<field name="api_secret" string="Client Secret" password="1" placeholder="••••••••••••••••"/>
<field name="is_syncing" invisible="1"/>
<field name="barcode_is_pushing" invisible="1"/>
<field name="barcode_error_count" invisible="1"/>
</group>
<div class="alert alert-info py-2 mt-1" style="font-size: 0.82em;">
<i class="fa fa-info-circle mr-1"/>
Ingresa el <strong>Client ID</strong> y <strong>Client Secret</strong> de tu App en Shopify y presiona <strong>Generar Token</strong>. El token dura 24h y se renueva solo.
</div>
<div class="mt-2 mb-3">
<button name="action_get_access_token" string="⚡ Generar Token" type="object" icon="fa-key" class="btn btn-primary mr-2"/>
<button name="action_test_connection" string="Probar Conexión" type="object" icon="fa-check-circle" class="btn btn-info"/>
</div>
<group string="🔒 Token Activo">
<field name="access_token" password="1" readonly="1" string="Access Token"/>
<field name="access_token_expiry" readonly="1" string="Válido hasta"/>
</group>
</div>
<div class="col-6">
<group string="📁 Archivo Maestro (CSV)">
<field name="csv_file" filename="csv_filename" help="Sube el archivo REV2 SKU .csv aquí"/>
<field name="csv_filename" invisible="1" force_save="1"/>
</group>
<div class="alert alert-warning py-2 mt-1" style="font-size: 0.82em;">
<i class="fa fa-exclamation-triangle mr-1"/>
El CSV se utiliza para asignar las nomenclaturas personalizadas de SKU cargadas por el equipo de diseño.
</div>
</div>
</div>
</page>
<!-- HERRAMIENTAS LEGACY -->
<page string="⚙️ Herramientas de Mantenimiento" name="legacy_tools">
<notebook>
<!-- 3. ASIGNAR SKUS -->
<page string="🏷️ Paso 1: Asignar SKUs" name="sku_simulation">
<div style="background: #F8FAFC; padding: 15px; border-radius: 8px; border: 1px solid #E2E8F0; margin-bottom: 15px;">
<div class="row">
<div class="col-12 mb-2">
<div class="d-flex align-items-center">
<i class="fa fa-search mr-2 text-muted"/>
<field name="search_term" placeholder="Buscar por nombre, variante o SKU en todas las pestañas..." nolabel="1" class="w-100"/>
</div>
</div>
</div>
<div class="row">
<div class="col-4">
<div class="o_form_label font-weight-bold mb-1">Filtrar por Categoría:</div>
<field name="filter_category" widget="selection" nolabel="1"/>
</div>
<div class="col-4">
<div class="o_form_label font-weight-bold mb-1">Marcar por Categoría:</div>
<div class="d-flex">
<field name="select_category" placeholder="Categoría..." class="mr-2"/>
<button name="action_select_by_category" string="Marcar" type="object" class="btn btn-primary btn-sm" icon="fa-filter"/>
</div>
</div>
<div class="col-4">
<div class="o_form_label font-weight-bold mb-1">Acciones Rápidas:</div>
<div>
<button name="action_simulate_skus" string="⚙️ Simular SKUs (Paso 1)" type="object" class="btn btn-secondary btn-sm mr-1" icon="fa-refresh" attrs="{'invisible': [('is_syncing', '=', True)]}"/>
<button name="action_apply_skus_to_shopify" string="📤 Aplicar SKUs (Paso 2)" type="object" class="btn btn-warning btn-sm mr-1" icon="fa-cloud-upload" attrs="{'invisible': [('is_syncing', '=', True)]}" confirm="¿Actualizar los SKUs seleccionados en Shopify?"/>
<button name="action_export_sku_barcodes" string="Exportar SKU + Barcode" type="object" class="btn btn-outline-info btn-sm" icon="fa-download"/>
<button name="action_release_lock" string="Liberar bloqueo" type="object" class="btn btn-link text-danger btn-sm d-block mt-1" attrs="{'invisible': [('is_syncing', '=', False)]}" confirm="¿Liberar el bloqueo de sincronización?"/>
</div>
</div>
</div>
</div>
<notebook>
<page string="PASO 0: Duplicados en Shopify" name="sku_duplicates" attrs="{'invisible': [('duplicate_count', '=', 0)]}">
<div class="mb-3 d-flex align-items-center">
<div class="flex-grow-1">
<p class="text-danger font-weight-bold mb-0">
<i class="fa fa-exclamation-triangle mr-1"/>
Se detectaron SKUs repetidos en tu tienda. Se recomienda corregirlos usando el "SKU Sugerido" antes de continuar.
</p>
</div>
<button name="action_export_duplicates" string="Exportar Reporte para Cliente (CSV)" type="object" class="btn btn-outline-primary" icon="fa-download"/>
</div>
<field name="duplicate_report_file" filename="duplicate_report_filename" invisible="1"/>
<field name="duplicate_report_filename" invisible="1"/>
<field name="duplicate_line_ids" search="1">
<tree editable="bottom" decoration-danger="is_shopify_duplicate" default_order="old_sku">
<field name="is_shopify_duplicate" invisible="1"/>
<field name="is_selected" widget="boolean_toggle" string="Sync?"/>
<field name="image_html" string="Imagen" readonly="1"/>
<field name="product_name"/>
<field name="variant_name"/>
<field name="old_sku" string="SKU Duplicado en Web" class="font-weight-bold"/>
<field name="duplicate_with" string="¿Con quién choca?" class="text-danger small"/>
<field name="suggested_sku" string="SKU Sugerido (Corrección)" class="text-primary"/>
<field name="sku_category_code"/>
<field name="sync_state" widget="badge" decoration-info="sync_state == 'draft'"/>
</tree>
</field>
</page>
<page string="PASO 1: Sin SKU" name="sku_pending">
<div class="mb-2 d-flex align-items-center">
<button name="action_select_all" string="Marcar Todos" type="object" class="btn-sm btn-outline-info mr-1" icon="fa-check-square-o"/>
<button name="action_unselect_all" string="Desmarcar Todos" type="object" class="btn-sm btn-outline-secondary" icon="fa-square-o"/>
</div>
<field name="pending_line_ids" search="1">
<tree editable="bottom" decoration-danger="sync_state == 'error' or is_shopify_duplicate">
<field name="is_shopify_duplicate" invisible="1"/>
<field name="is_selected" widget="boolean_toggle" string="Sync?"/>
<field name="image_html" string="Imagen" readonly="1"/>
<field name="product_name" readonly="1"/>
<field name="variant_name" readonly="1"/>
<field name="sku_category_code" readonly="1"/>
<field name="old_sku" readonly="1"/>
<field name="sku_source" widget="badge" decoration-success="sku_source == 'csv'" decoration-info="sku_source == 'logic'" readonly="1"/>
<field name="sync_state" widget="badge" decoration-danger="sync_state == 'error'" decoration-warning="sync_state == 'has_sku'" decoration-info="sync_state == 'draft'" readonly="1"/>
<field name="sync_history" string="Respuesta Shopify" optional="show"/>
<field name="shopify_handle" optional="hide"/>
<field name="shopify_title" optional="hide"/>
</tree>
</field>
</page>
<page string="PASO 2: Con SKU" name="sku_history">
<field name="synced_line_ids" readonly="1" search="1">
<tree decoration-success="sync_state == 'synced'">
<field name="image_html" string="Imagen" readonly="1"/>
<field name="product_name"/>
<field name="variant_name"/>
<field name="sku_category_code"/>
<field name="old_sku" string="SKU en Shopify"/>
<field name="barcode" string="Cód. de Barras" optional="show"/>
<field name="sku_source" widget="badge" decoration-success="sku_source == 'csv'" decoration-info="sku_source == 'logic'"/>
<field name="sync_state" widget="badge" decoration-success="sync_state == 'synced'" decoration-warning="sync_state == 'has_sku'" decoration-info="sync_state == 'draft'"/>
<field name="shopify_handle" optional="hide"/>
<field name="shopify_title" optional="hide"/>
</tree>
</field>
</page>
</notebook>
</page>
<!-- 4. IMPORTAR CATÁLOGO -->
<page string="📦 Paso 2: Importar Catálogo" name="sku_all">
<!-- ═══ PANEL A: CATÁLOGO ═══ -->
<div style="background:#e8f4fd; padding:12px 15px; border-radius:8px; margin-bottom:10px; border-left:4px solid #0d6efd;">
<div style="font-weight:bold; color:#0d6efd; margin-bottom:8px;">
📦 GESTIÓN DE CATÁLOGO — Importar productos nuevos de Shopify a Odoo
</div>
<div style="display:flex; gap:8px; flex-wrap:wrap; align-items:center;">
<field name="total_variant_count" readonly="1" nolabel="1" style="display:none;"/>
<span style="font-size:12px; color:#6c757d;">
Total: <field name="total_variant_count" readonly="1" nolabel="1"/> |
En Odoo ✅: <field name="imported_variant_count" readonly="1" nolabel="1"/> |
Pendientes ⏳: <field name="pending_variant_count" readonly="1" nolabel="1"/> |
Seleccionados: <field name="selected_variant_count" readonly="1" nolabel="1"/>
</span>
<button name="action_select_all_pending" type="object"
string="✅ Marcar Pendientes de Importar"
class="btn-outline-primary btn-sm"
icon="fa-check-square-o"/>
<button name="action_unselect_all" type="object"
string="⬜ Desmarcar Todo"
class="btn-outline-secondary btn-sm"
icon="fa-square-o"/>
<button name="action_mass_import_to_odoo" type="object"
string="🚀 IMPORTAR SELECCIONADOS A ODOO"
class="btn-primary btn-sm"
icon="fa-cloud-download"/>
<button name="action_audit_inventory" type="object"
string="🔍 Auditar Catálogo"
class="btn-outline-warning btn-sm"
icon="fa-search"/>
</div>
</div>
<!-- ═══ PANEL B: STOCK ═══ -->
<div style="background:#e8f8f0; padding:12px 15px; border-radius:8px; margin-bottom:12px; border-left:4px solid #198754;">
<div style="font-weight:bold; color:#198754; margin-bottom:8px;">
📊 ACTUALIZAR STOCK — Traer cantidades de Shopify a Odoo (Shopify manda)
</div>
<div style="display:flex; gap:8px; flex-wrap:wrap; align-items:center;">
<button name="action_select_stock_discrepancies" type="object"
string="⚠️ Marcar Discrepancias de Stock"
class="btn-outline-success btn-sm"
icon="fa-exclamation-triangle"
help="Selecciona solo los productos que tienen diferencia entre Shopify y Odoo"/>
<button name="action_update_stock_from_shopify" type="object"
string="📥 Actualizar Stock Seleccionado (Shopify → Odoo)"
class="btn-success btn-sm"
icon="fa-refresh"
confirm="¿Actualizar las cantidades en Odoo para los productos seleccionados? Shopify es la fuente de verdad."/>
<button name="action_release_lock" type="object"
string="🔓 Liberar bloqueo"
class="btn-link btn-sm"
style="color:#dc3545;"
confirm="¿Liberar el bloqueo de sincronización?"/>
</div>
</div>
<field name="all_line_ids" readonly="1" search="1">
<tree decoration-info="exists_in_odoo" decoration-muted="exists_in_odoo">
<field name="is_selected" string="Sel?" attrs="{'invisible': [('exists_in_odoo', '=', True)]}"/>
<field name="image_html" string="Imagen" readonly="1"/>
<field name="product_name" readonly="1"/>
<field name="variant_name" readonly="1"/>
<field name="old_sku" readonly="1"/>
<field name="shopify_qty_total" string="Stock Shopify"/>
<field name="odoo_qty_total" string="Stock Odoo"/>
<button name="action_refresh_shopify_stock_info" string="Consultar" type="object" icon="fa-refresh" class="btn-sm btn-secondary"/>
<field name="barcode" string="Cód. de Barras" optional="hide" readonly="1"/>
<field name="exists_in_odoo" string="En Odoo" readonly="1"/>
<button name="action_import_to_odoo" string="Importar" type="object"
class="btn-primary btn-sm" icon="fa-download"
attrs="{'invisible': [('exists_in_odoo', '=', True)]}"/>
<button name="action_sync_inventory_only" string="↓ Act. Stock" type="object"
class="btn-success btn-sm" icon="fa-refresh"
attrs="{'invisible': [('exists_in_odoo', '=', False)]}"
confirm="¿Traer stock de Shopify para este producto?"/>
<field name="sync_state" widget="badge"
decoration-success="sync_state == 'synced'"
decoration-warning="sync_state == 'has_sku'"
decoration-info="sync_state == 'draft'"
readonly="1" optional="hide"/>
<field name="generated_barcode" string="EAN-13" optional="hide" readonly="1"/>
<field name="barcode_sync_state" widget="badge"
decoration-success="barcode_sync_state == 'done'"
decoration-warning="barcode_sync_state == 'pending'"
decoration-danger="barcode_sync_state == 'error'"
optional="hide" readonly="1"/>
<field name="shopify_handle" optional="hide" readonly="1"/>
</tree>
</field>
</page>
<!-- 5. BARCODES EAN-13 -->
<page string="🔖 Paso 3: Barcodes EAN-13" name="ean13_barcodes">
<div class="row text-center mb-3 pb-3" style="border-bottom:1px solid #dee2e6;">
<div class="col-3">
<div class="text-muted small mb-1">Sin generar</div>
<div class="h3 text-secondary"><field name="barcode_none_count" readonly="1"/></div>
</div>
<div class="col-3">
<div class="text-muted small mb-1">Pendientes</div>
<div class="h3 text-warning"><field name="barcode_pending_count" readonly="1"/></div>
</div>
<div class="col-3">
<div class="text-muted small mb-1">Subidos ✓</div>
<div class="h3 text-success"><field name="barcode_pushed_count" readonly="1"/></div>
</div>
<div class="col-3">
<div class="text-muted small mb-1">Errores</div>
<div class="h3 text-danger"><field name="barcode_error_count" readonly="1"/></div>
</div>
</div>
<div class="d-flex align-items-center mb-3">
<div class="o_form_label mr-2 font-weight-bold">Prefijo empresa:</div>
<field name="barcode_prefix" style="width:130px;" class="mr-3"/>
<div class="o_form_label mr-2 text-muted">Progreso:</div>
<field name="barcode_progress" readonly="1" widget="float" digits="[3,1]" class="mr-1"/>
<span class="text-muted mr-3">%</span>
<span attrs="{'invisible': [('barcode_is_pushing', '=', False)]}"
class="badge badge-warning ml-2">
<i class="fa fa-spin fa-spinner mr-1"/>Subiendo barcodes...
</span>
</div>
<div class="mb-3 d-flex" style="gap:8px;">
<button name="action_generate_ean13"
string="1. Generar EAN-13"
type="object"
class="btn btn-primary"
icon="fa-barcode"
attrs="{'invisible': [('barcode_is_pushing', '=', True)]}"/>
<button name="action_push_barcodes_to_shopify"
string="2. Subir a Shopify"
type="object"
class="btn btn-success"
icon="fa-cloud-upload"
attrs="{'invisible': [('barcode_is_pushing', '=', True)]}"
confirm="¿Subir todos los barcodes pendientes a Shopify? El proceso corre en segundo plano."/>
<button name="action_retry_barcode_errors"
string="Reintentar errores"
type="object"
class="btn btn-warning"
icon="fa-refresh"
attrs="{'invisible': [('barcode_error_count', '=', 0)]}"/>
<button name="action_mark_barcodes_done"
string="✓ Marcar como Subidos"
type="object"
class="btn btn-info"
icon="fa-check-square-o"
attrs="{'invisible': [('barcode_pending_count', '=', 0)]}"
confirm="¿Marcar todos los pendientes como subidos correctamente? Hazlo solo si ya verificaste en Shopify."/>
<button name="action_export_barcode_labels"
string="Exportar Etiquetas CSV"
type="object"
class="btn btn-outline-secondary"
icon="fa-file-text-o"/>
<button name="action_export_inventory_excel"
string="Exportar Excel (Inventario + Próximos)"
type="object"
class="btn btn-success"
icon="fa-file-excel-o"/>
</div>
<div class="o_horizontal_separator font-weight-bold mb-3" style="font-size: 1.15em; color: #1E3A8A;">📋 Detalle de Códigos de Barras (EAN-13)</div>
<field name="simulation_line_ids" search="1">
<tree editable="bottom" create="false" delete="false" decoration-success="barcode_sync_state == 'done'" decoration-warning="barcode_sync_state == 'pending'" decoration-danger="barcode_sync_state == 'error'">
<field name="is_selected" widget="boolean_toggle" string="Sel?"/>
<field name="image_html" string="Imagen" readonly="1"/>
<field name="product_name" readonly="1"/>
<field name="variant_name" readonly="1"/>
<field name="old_sku" readonly="1"/>
<field name="barcode" string="Cód. de Barras (Shopify)" readonly="1"/>
<field name="generated_barcode" string="EAN-13 Generado" readonly="1"/>
<field name="barcode_sync_state" widget="badge"
decoration-success="barcode_sync_state == 'done'"
decoration-warning="barcode_sync_state == 'pending'"
decoration-danger="barcode_sync_state == 'error'"
readonly="1"/>
<field name="barcode_sync_error" string="Detalle de Error" readonly="1"/>
</tree>
</field>
<div class="alert alert-info mt-2" role="alert">
<i class="fa fa-info-circle mr-1"/>
Para ver el detalle EAN-13 por producto, ve a la pestaña
<strong>Paso 2: Importar Catálogo</strong> y activa las columnas
<em>EAN-13 Generado</em>, <em>Estado Barcode</em> y <em>Error Barcode</em>
desde el botón de columnas opcionales en el encabezado de la tabla.
</div>
</page>
<!-- PASO 4: IMPORTAR COSTOS -->
<page string="💰 Paso 4: Sincronizar Costos" name="sync_costs">
<div class="row">
<div class="col-md-4 text-center">
<h4>Pendientes</h4>
<h2 class="text-warning"><field name="cost_pending_count" readonly="1"/></h2>
</div>
<div class="col-md-4 text-center">
<h4>Subidos ✓</h4>
<h2 class="text-success"><field name="cost_done_count" readonly="1"/></h2>
</div>
<div class="col-md-4 text-center">
<h4>Errores</h4>
<h2 class="text-danger"><field name="cost_error_count" readonly="1"/></h2>
</div>
</div>
<hr/>
<div class="mb-3">
<field name="cost_is_fetching" invisible="1"/>
<field name="cost_is_pushing" invisible="1"/>
<button name="action_fetch_shopify_costs"
string="1. Consultar Costos"
type="object"
class="btn btn-primary"
icon="fa-download"
attrs="{'invisible': [('cost_is_fetching', '=', True)]}"/>
<span class="text-muted ml-2" attrs="{'invisible': [('cost_is_fetching', '=', False)]}">
<i class="fa fa-spinner fa-spin"/> Consultando Costos en Shopify...
</span>
<button name="action_apply_shopify_costs"
string="2. Aplicar Costos a Odoo"
type="object"
class="btn btn-success ml-2"
icon="fa-cloud-upload"
attrs="{'invisible': ['|', ('cost_pending_count', '=', 0), ('cost_is_pushing', '=', True)]}"/>
<span class="text-muted ml-2" attrs="{'invisible': [('cost_is_pushing', '=', False)]}">
<i class="fa fa-spinner fa-spin"/> Aplicando Costos en Odoo...
</span>
</div>
<div class="o_horizontal_separator font-weight-bold mb-3" style="font-size: 1.15em; color: #1E3A8A;">📋 Detalle de Costos Extraídos</div>
<field name="cost_line_ids" search="1">
<tree editable="bottom" create="false" delete="false" decoration-success="state == 'done'" decoration-warning="state == 'pending'" decoration-danger="state == 'error'">
<field name="product_tmpl_id" readonly="1"/>
<field name="product_id" readonly="1"/>
<field name="default_code" readonly="1"/>
<field name="shopify_inventory_item_id" readonly="1" optional="hide"/>
<field name="current_cost" string="Costo Actual" readonly="1"/>
<field name="new_cost" string="Costo Shopify" readonly="1"/>
<field name="state" widget="badge"
decoration-success="state == 'done'"
decoration-warning="state == 'pending'"
decoration-danger="state == 'error'"
readonly="1"/>
<field name="error_message" readonly="1"/>
</tree>
</field>
</page>
</notebook>
</page>
<!-- 6. SUCURSALES (MAPEO) -->
<page string="🏬 Sucursales (Mapeo)" name="sucursales">
<group>
<group string="Sincronización de Stock">
<button name="action_sync_inventory" string="Sincronizar Stock" type="object" class="btn-primary" icon="fa-refresh"/>
</group>
<group string="Sucursal Principal Shopify">
<field name="shopify_location_name" readonly="1" string="Nombre Sucursal"/>
<field name="shopify_location_id" readonly="1" string="ID Sucursal"/>
<button name="action_fetch_locations" string="🔄 Actualizar Ubicaciones" type="object" class="btn-secondary" icon="fa-refresh"/>
</group>
</group>
<div class="alert alert-info" role="alert">
<strong>Mapeo de Sucursales:</strong> Vincula cada sucursal de Shopify con un almacén o ubicación de Odoo para sincronizar el stock correctamente.
</div>
<field name="location_mapping_ids">
<tree editable="bottom" create="false" delete="false">
<field name="shopify_location_name" readonly="1"/>
<field name="shopify_location_id" readonly="1"/>
<field name="odoo_warehouse_id"/>
<field name="odoo_location_id" domain="[('warehouse_id', '=', odoo_warehouse_id)]"/>
</tree>
</field>
</page>
<!-- 7. REGLAS DE VENTAS -->
<page string="🛒 Reglas de Ventas" name="sales_config">
<group>
<group string="Configuración Financiera">
<field name="order_pricelist_id" required="1"/>
<field name="order_journal_id" required="1"/>
<field name="default_warehouse_id" required="1"/>
<field name="sales_team_id"/>
</group>
<group string="Automatizaciones">
<field name="auto_create_draft_invoice"/>
<field name="auto_workflow_payment" attrs="{'invisible': [('auto_create_draft_invoice', '=', False)]}"/>
<field name="payment_journal_id" attrs="{'invisible': [('auto_workflow_payment', '=', False)], 'required': [('auto_workflow_payment', '=', True)]}"/>
<field name="auto_sync_products"/>
<field name="auto_process_refunds"/>
<field name="shopify_sandbox_mode"/>
</group>
</group>
<group string="Estado de Sincronización">
<field name="last_order_sync_date" string="Buscar pedidos desde" help="Puedes editar esta fecha para buscar ventas pasadas (ej. de la última semana). Si la dejas en blanco, traerá las últimas 100 ventas."/>
<field name="shopify_import_cutoff_date" string="Fecha de Corte de Inventario" help="Pedidos de Shopify anteriores a esta fecha no descontarán stock en Odoo."/>
<button name="action_import_orders" string="📥 IMPORTAR PEDIDOS RECIENTES" type="object" class="btn btn-primary mt-2" attrs="{'invisible': [('is_syncing', '=', True)]}"/>
</group>
<div class="alert alert-warning text-center" role="alert" attrs="{'invisible': [('shopify_sandbox_mode', '=', False)]}" style="font-weight: bold; padding: 8px; font-size: 1.05em;">
🛡️ MODO SANDBOX ACTIVO: Las devoluciones y reembolsos (tanto manuales como automáticos) solo se procesarán para pedidos con productos de prueba (SKU contiene 'PRUEBA'). Los pedidos reales de clientes están protegidos.
</div>
</page>
<!-- 8. TAREAS PROGRAMADAS (CRONS) -->
<page string="⏰ Tareas Programadas (Crons)" name="cron_config">
<!-- Banner Superior Premium -->
<div class="bg-gradient-primary text-white p-4 rounded mb-4" style="background: linear-gradient(135deg, #1e3c72 0%, #2a5298 100%); border-radius: 8px; box-shadow: 0 4px 15px rgba(0,0,0,0.1); margin-bottom: 25px; padding: 15px; color: white !important;">
<div class="d-flex align-items-center">
<div style="font-size: 2.2em; margin-right: 15px; float: left;"></div>
<div style="margin-left: 60px;">
<h3 class="m-0" style="font-weight: 700; color: white; margin-top: 0; margin-bottom: 5px;">Panel de Automatizaciones (Cron Jobs)</h3>
<p class="m-0" style="opacity: 0.9; font-size: 0.95em; color: white; margin: 0;">Configura y monitorea en tiempo real los procesos automáticos en segundo plano que sincronizan Odoo y Shopify de manera continua.</p>
</div>
</div>
<div style="clear: both;"/>
</div>
<group>
<!-- 1. Cron de Pedidos -->
<group string="📥 Importación de Pedidos (Shopify ➔ Odoo)">
<div class="alert alert-info" role="alert" colspan="2" style="margin-bottom: 15px; border-left: 4px solid #17a2b8; padding: 10px; border-radius: 4px; color: #0c5460; background-color: #d1ecf1; border-color: #bee5eb; width: 100%; display: block;">
💡 <b>¿Qué hace?</b> Busca y descarga automáticamente nuevos pedidos pagados o autorizados de Shopify a Odoo.<br/>
💡 <b>Caso de uso:</b> Registra las ventas en tiempo real sin intervención manual para que almacén pueda facturar y empacar de inmediato.<br/>
💡 <b>Frecuencia recomendada:</b> 15 o 30 minutos en producción.<br/>
💡 <b>Seguridad:</b> No altera el stock físico de Odoo, solo lee datos de Shopify y escribe órdenes nuevas en Odoo.
</div>
<field name="auto_import_orders" string="Activar Importación Automática" help="Activa la búsqueda y descarga automatizada de nuevos pedidos de Shopify a Odoo en segundo plano."/>
<field name="order_sync_interval" string="Frecuencia de Búsqueda" attrs="{'invisible': [('auto_import_orders', '=', False)]}" help="Intervalo de tiempo con el que se ejecuta la importación de pedidos. Frecuencia recomendada para producción: 15 o 30 minutos."/>
<field name="cron_orders_status" string="Estado de Ejecución" readonly="1" style="font-weight: bold;"/>
</group>
<!-- 2. Cron de Inventario -->
<group string="📤 Sincronización de Inventario (Odoo ➔ Shopify)">
<div class="alert alert-success" role="alert" colspan="2" style="margin-bottom: 15px; border-left: 4px solid #28a745; padding: 10px; border-radius: 4px; color: #155724; background-color: #d4edda; border-color: #c3e6cb; width: 100%; display: block;">
🔥 <b>CRÍTICO DE SEGURIDAD:</b> Envía el inventario físico real de Odoo hacia Shopify para mantenerlos alineados.<br/>
💡 <b>¿Qué hace?</b> Si las existencias de un producto en Odoo llegan a 0 unidades, cambia el botón en Shopify automáticamente a "Agotado" para evitar sobreventas.<br/>
💡 <b>Frecuencia recomendada:</b> 30 o 60 minutos en producción, o 12 horas en fases iniciales.<br/>
⚠️ <b>Regla de Oro:</b> Odoo es la Fuente de Verdad. Este cron reescribe el inventario de Shopify con las unidades físicas de Odoo.
</div>
<field name="cron_sync_inventory_active" string="Activar Envío de Stock" help="CRÍTICO: Activa la actualización automática del inventario desde Odoo hacia Shopify en segundo plano."/>
<field name="cron_sync_inventory_interval" string="Frecuencia de Stock" attrs="{'invisible': [('cron_sync_inventory_active', '=', False)]}" help="Intervalo de ejecución para actualizar las existencias en Shopify. Frecuencia recomendada: 30 o 60 minutos en producción."/>
<field name="cron_inventory_status" string="Estado de Ejecución" readonly="1" style="font-weight: bold;"/>
</group>
</group>
<group>
<!-- 3. Cron de Imágenes -->
<group string="📸 Auto-Descarga de Imágenes (Shopify ➔ Odoo)">
<div class="alert alert-warning" role="alert" colspan="2" style="margin-bottom: 15px; border-left: 4px solid #ffc107; padding: 10px; border-radius: 4px; color: #856404; background-color: #fff3cd; border-color: #ffeeba; width: 100%; display: block;">
📸 <b>¿Qué hace?</b> Busca periódicamente productos mapeados o creados en Odoo que no tengan imagen asignada y descarga su foto de portada desde Shopify.<br/>
💡 <b>Caso de uso:</b> Mantiene tu base de datos de Odoo visualmente completa de forma automática.<br/>
💡 <b>Frecuencia recomendada:</b> 2 o 6 horas.<br/>
💡 <b>Seguridad:</b> Se ejecuta en segundo plano de manera ligera por lotes pequeños para no ralentizar el servidor.
</div>
<field name="auto_import_images" string="Activar Descarga de Fotos" help="Busca automáticamente productos que no tengan imagen en Odoo y descarga sus portadas desde Shopify."/>
<field name="image_sync_interval" string="Frecuencia de Fotos" attrs="{'invisible': [('auto_import_images', '=', False)]}" help="Frecuencia con la que se buscarán nuevas imágenes de Shopify. Se recomienda 2 o 6 horas."/>
<field name="cron_images_status" string="Estado de Ejecución" readonly="1" style="font-weight: bold;"/>
</group>
<!-- 4. Cron de Renovación de Tokens -->
<group string="🔑 Renovación de Tokens de Acceso (OAuth)">
<div class="alert alert-secondary" role="alert" colspan="2" style="margin-bottom: 15px; border-left: 4px solid #6c757d; padding: 10px; border-radius: 4px; color: #383d41; background-color: #e2e3e5; border-color: #d6d8db; width: 100%; display: block;">
🔒 <b>Proceso Técnico Automatizado:</b><br/>
Shopify emite tokens de acceso con una duración máxima de 24 horas.<br/>
Este cron técnico interno renueva automáticamente las llaves de acceso cada 20 horas para prevenir desconexiones de servicio y asegurar que las integraciones nunca expiren.
</div>
<field name="cron_tokens_status" string="Estado de Renovación" readonly="1" style="font-weight: bold;"/>
</group>
</group>
</page>
</notebook>
</sheet>
</form>
</field>
</record>
<!-- Tree View -->
<record id="view_shopify_config_tree" model="ir.ui.view">
<field name="name">shopify.config.tree</field>
<field name="model">shopify.config</field>
<field name="arch" type="xml">
<tree>
<field name="name"/>
<field name="shop_url"/>
</tree>
</field>
</record>
<!-- Action -->
<record id="action_shopify_config" model="ir.actions.act_window">
<field name="name">Configuración de Shopify</field>
<field name="res_model">shopify.config</field>
<field name="view_mode">tree,form</field>
</record>
<!-- Estructura de Menú Ad Hoc (Nivel Principal) con Icono de Shopify -->
<menuitem id="menu_shopify_root"
name="Shopify Connect"
web_icon="fa fa-shopping-bag,#96bf48,#FFFFFF"
groups="hazard_shopify.group_hazard_finance,base.group_system"
sequence="1"/>
<menuitem id="menu_shopify_dashboard"
name="Panel de Control"
parent="menu_shopify_root"
action="action_shopify_config"
sequence="10"/>
<!-- Mantener rastro del menú anterior por si queremos regresar (Comentado) -->
<!--
<menuitem id="menu_shopify_config_old" name="Configuración de Tiendas" parent="sale.menu_sale_config" action="action_shopify_config" sequence="100"/>
-->
</data>
<!-- Simulation Line Form View (Detalle del Producto) -->
<record id="view_shopify_sku_simulation_line_form" model="ir.ui.view">
<field name="name">shopify.sku.simulation.line.form</field>
<field name="model">shopify.sku.simulation.line</field>
<field name="arch" type="xml">
<form string="Detalle de Producto Shopify">
<header>
<button name="action_refresh_shopify_stock_info" string="🔍 Solo Consultar Stock Shopify" type="object" class="btn-secondary"/>
<button name="action_import_to_odoo" string="Importar a Odoo" type="object" class="btn-primary" attrs="{'invisible': [('exists_in_odoo', '=', True)]}"/>
<button name="action_sync_inventory_only"
string="📥 Traer Stock de Shopify a Odoo"
type="object"
class="btn-info"
attrs="{'invisible': [('exists_in_odoo', '=', False)]}"
confirm="¿Estás seguro? Esto ajustará las cantidades de Odoo según lo que hay actualmente en Shopify para este producto."/>
</header>
<sheet>
<div class="oe_title">
<label for="product_name" string="Producto"/>
<h1><field name="product_name"/></h1>
<h3><field name="variant_name" attrs="{'invisible': [('variant_name', '=', 'Default Title')]}"/></h3>
</div>
<group>
<group string="Identificadores">
<field name="old_sku" string="SKU (Shopify)"/>
<field name="barcode" string="Código de Barras"/>
<field name="shopify_variant_id" groups="base.group_no_one"/>
<field name="shopify_inventory_item_id" groups="base.group_no_one"/>
</group>
<group string="Estado en Odoo">
<field name="exists_in_odoo" readonly="1"/>
<field name="is_imported" readonly="1"/>
<field name="last_sync_date" string="Última Sincronización" readonly="1"/>
<field name="sync_state" widget="badge"/>
</group>
</group>
<group>
<group string="Inventario en Shopify (Real Time)">
<field name="shopify_stock_info" widget="html" nolabel="1" class="text-info font-weight-bold" style="font-size: 1.1em;"/>
</group>
<group string="Inventario en Odoo (Actual)">
<field name="odoo_stock_info" widget="html" nolabel="1" class="text-success font-weight-bold" style="font-size: 1.1em;"/>
</group>
</group>
<notebook>
<page string="Historial de Sincronización">
<field name="sync_history" readonly="1" placeholder="Aquí aparecerán los éxitos y errores de sincronización..."/>
</page>
</notebook>
</sheet>
</form>
</field>
</record>
<!-- Wizard View -->
<record id="view_shopify_location_wizard_form" model="ir.ui.view">
<field name="name">shopify.location.wizard.form</field>
<field name="model">shopify.location.wizard</field>
<field name="arch" type="xml">
<form string="Seleccionar Sucursal Shopify">
<p class="oe_grey">
Selecciona la sucursal de Shopify que quieres vincular con este almacén de Odoo.
</p>
<field name="config_id" invisible="1"/>
<field name="line_ids">
<tree editable="bottom" create="false" delete="false">
<field name="is_selected" widget="boolean_toggle"/>
<field name="name"/>
<field name="shopify_location_id"/>
</tree>
</field>
<footer>
<button name="action_confirm" string="Vincular Sucursal" type="object" class="btn-primary"/>
<button string="Cancelar" class="btn-secondary" special="cancel"/>
</footer>
</form>
</field>
</record>
</odoo>
@@ -0,0 +1,2 @@
# -*- coding: utf-8 -*-
from . import shopify_refund_wizard
@@ -0,0 +1,337 @@
# -*- coding: utf-8 -*-
import requests
import logging
from odoo import models, fields, api, _
from odoo.exceptions import UserError
_logger = logging.getLogger(__name__)
class ShopifyRefundWizard(models.TransientModel):
_name = 'shopify.refund.wizard'
_description = 'Asistente de Reembolso Shopify'
order_id = fields.Many2one('sale.order', string='Pedido de Venta', required=True)
shopify_order_id = fields.Char(string='ID Orden Shopify', related='order_id.shopify_order_id', readonly=True)
shopify_order_number = fields.Char(string='Pedido Shopify', related='order_id.shopify_order_number', readonly=True)
line_ids = fields.One2many('shopify.refund.wizard.line', 'wizard_id', string='Productos a Reembolsar')
note = fields.Text(string='Motivo del Reembolso')
restock = fields.Boolean(
string='Regresar Productos al Stock', default=True,
help="Si está marcado, los artículos se devolverán al inventario en Shopify y Odoo."
)
notify_customer = fields.Boolean(
string='Notificar al Cliente por Email', default=True,
help="Si está activo, Shopify le enviará un correo automático de reembolso al cliente."
)
amount_to_refund = fields.Float(
string='Monto Estimado a Reembolsar',
compute='_compute_amount_to_refund',
readonly=True,
help="Suma de los precios de venta de las cantidades seleccionadas."
)
@api.depends('line_ids.quantity_to_refund', 'line_ids.price_unit')
def _compute_amount_to_refund(self):
for rec in self:
rec.amount_to_refund = sum(
line.price_unit * line.quantity_to_refund
for line in rec.line_ids
)
@api.model
def default_get(self, fields_list):
"""
Carga el order_id y sus líneas de productos asociadas directamente.
"""
res = super().default_get(fields_list)
active_id = self.env.context.get('active_id')
if active_id:
order = self.env['sale.order'].sudo().browse(active_id)
if order.exists():
res['order_id'] = order.id
# Cargar líneas directamente en el default_get
new_lines = []
for line in order.order_line.filtered(lambda l: not l.display_type and l.product_id):
new_lines.append((0, 0, {
'product_id': line.product_id.id,
'odoo_sku': line.product_id.default_code or '',
'product_name': line.product_id.name or '',
'shopify_line_item_id': '',
'quantity_ordered': line.product_uom_qty,
'quantity_refunded': 0.0,
'quantity_to_refund': 0.0,
'price_unit': line.price_unit,
}))
res['line_ids'] = new_lines
_logger.info(
"[Reembolso Wizard] [default_get] Cargadas %s líneas de productos para pedido %s",
len(new_lines), order.name
)
return res
@api.onchange('order_id')
def _onchange_order_id(self):
"""
Cuando se fija el pedido de venta, carga automáticamente los productos
de las líneas del pedido de Odoo para mostrarlos en el wizard.
NO llama a Shopify en este paso — los IDs de línea de Shopify
se resuelven en el momento de confirmar el reembolso.
"""
self.line_ids = [(5, 0, 0)] # Limpia líneas anteriores
if not self.order_id:
return
order = self.order_id.sudo()
new_lines = []
for line in order.order_line.filtered(lambda l: not l.display_type and l.product_id):
new_lines.append((0, 0, {
'product_id': line.product_id.id,
'odoo_sku': line.product_id.default_code or '',
'product_name': line.product_id.name or '',
'shopify_line_item_id': '', # Se resuelve al confirmar
'quantity_ordered': line.product_uom_qty,
'quantity_refunded': 0.0,
'quantity_to_refund': 0.0,
'price_unit': line.price_unit,
}))
self.line_ids = new_lines
_logger.info(
"[Reembolso Wizard] Cargadas %s líneas de productos para pedido %s",
len(new_lines), order.name
)
def action_confirm_refund(self):
self.ensure_one()
# ── LOGGING EXTENSIVO SOLICITADO POR EL USUARIO ───────────────────────
_logger.info("=" * 60)
_logger.info("[Reembolso Wizard] action_confirm_refund llamada.")
_logger.info("[Reembolso Wizard] ID del Registro Wizard: %s", self.id)
_logger.info("[Reembolso Wizard] Pedido Odoo ID: %s (%s)", self.order_id.id if self.order_id else 'None', self.order_id.name if self.order_id else 'None')
_logger.info("[Reembolso Wizard] Pedido Shopify ID: %s, Número: %s", self.shopify_order_id, self.shopify_order_number)
_logger.info("[Reembolso Wizard] Total de líneas en wizard (self.line_ids): %s", len(self.line_ids))
for idx, line in enumerate(self.line_ids, 1):
_logger.info(
"[Reembolso Wizard] Línea #%s -> ID: %s, product_id: %s (%s), SKU: %s, "
"Cant. Original: %s, Cant. Reembolsada: %s, A Reembolsar: %s, Precio Unitario: %s",
idx, line.id, line.product_id.id if line.product_id else 'None',
line.product_name or 'None', line.odoo_sku or 'None',
line.quantity_ordered, line.quantity_refunded, line.quantity_to_refund, line.price_unit
)
_logger.info("=" * 60)
# ── 1. Validar que hay al menos un producto seleccionado ──────────────
refund_lines = self.line_ids.filtered(lambda l: l.quantity_to_refund > 0)
if not refund_lines:
raise UserError(
"Por favor selecciona al menos 1 artículo con cantidad "
"mayor a 0 para reembolsar."
)
config = self.env['shopify.config'].sudo().search(
[('state', '=', 'confirmed')], limit=1
)
if not config:
raise UserError("No hay una configuración activa de Shopify para procesar el reembolso.")
# ── 2. Candado Sandbox ────────────────────────────────────────────────
if config.shopify_sandbox_mode:
has_real_products = any(
not l.display_type and l.product_id and
'PRUEBA' not in (l.product_id.default_code or '').upper()
for l in self.order_id.order_line
)
if has_real_products:
raise UserError(
"🛡️ [SEGURIDAD SANDBOX] Bloqueo de Seguridad Activo.\n\n"
"Este pedido contiene artículos reales de producción. "
"No está permitido realizar reembolsos hacia Shopify en vivo "
"para pedidos reales mientras el 'Modo Sandbox' esté activo."
)
# ── 3. Conectar a Shopify y obtener el pedido actualizado ─────────────
token = config._ensure_access_token()
shop_domain = (
config.shop_url.strip().lower()
.replace('https://', '').replace('http://', '')
.split('/')[0]
)
headers = {
'X-Shopify-Access-Token': token,
'Content-Type': 'application/json',
}
order_url = (
f"https://{shop_domain}/admin/api/2024-01/orders/"
f"{self.shopify_order_id}.json"
)
order_resp = requests.get(order_url, headers=headers, timeout=15)
if order_resp.status_code != 200:
raise UserError(
"No se pudo consultar el pedido en Shopify.\n"
"Código HTTP: %s\nDetalle: %s" % (order_resp.status_code, order_resp.text)
)
s_order = order_resp.json().get('order', {})
# Indexar line_items de Shopify por SKU y por título para encontrar el ID
shopify_items_by_sku = {}
shopify_items_by_name = {}
for item in s_order.get('line_items', []):
sku = (item.get('sku') or '').strip()
title = (item.get('title') or '').strip().lower()
if sku:
shopify_items_by_sku[sku] = item
if title:
shopify_items_by_name[title] = item
# ── 4. Construir calc_line_items buscando el ID de Shopify ─────────────
calc_line_items = []
for line in refund_lines:
sku = (line.odoo_sku or '').strip()
name = (line.product_name or (line.product_id.name if line.product_id else '') or '').strip().lower()
# Buscar por SKU exacto primero, luego por nombre
s_item = shopify_items_by_sku.get(sku)
if not s_item and name:
s_item = shopify_items_by_name.get(name)
if not s_item:
product_display = line.product_id.display_name if line.product_id else '(sin producto)'
raise UserError(
"No se encontró el producto '%s' en el pedido de Shopify %s.\n"
"Verifica que el SKU o nombre del producto coincida." % (
product_display, self.shopify_order_number or self.shopify_order_id
)
)
shopify_line_id = s_item.get('id')
_logger.info(
"[Reembolso] Mapeando '%s' → Shopify line_item_id=%s",
line.product_id.display_name if line.product_id else sku,
shopify_line_id
)
calc_line_item = {
'line_item_id': int(shopify_line_id),
'quantity': int(line.quantity_to_refund),
}
if self.restock:
calc_line_item['restock_type'] = 'return'
calc_line_item['location_id'] = int(config.shopify_location_id or 0)
calc_line_items.append(calc_line_item)
# ── 5. Paso A: Calculate ───────────────────────────────────────────────
calc_url = (
f"https://{shop_domain}/admin/api/2024-01/orders/"
f"{self.shopify_order_id}/refunds/calculate.json"
)
calc_payload = {
'refund': {
'shipping': {'full_refund': False},
'refund_line_items': calc_line_items,
}
}
_logger.info("[Reembolso API] Calculando reembolso para orden Shopify %s", self.shopify_order_number)
calc_resp = requests.post(calc_url, headers=headers, json=calc_payload, timeout=20)
if calc_resp.status_code != 200:
raise UserError(
"Error al calcular el reembolso en Shopify.\n"
"Código HTTP: %s\nDetalle: %s" % (calc_resp.status_code, calc_resp.text)
)
calc_data = calc_resp.json().get('refund', {})
# ── 6. Paso B: Crear el Reembolso Definitivo ───────────────────────────
refund_url = (
f"https://{shop_domain}/admin/api/2024-01/orders/"
f"{self.shopify_order_id}/refunds.json"
)
transactions = [
{
'parent_id': t.get('parent_id'),
'amount': t.get('amount'),
'kind': 'refund',
'gateway': t.get('gateway'),
}
for t in calc_data.get('transactions', [])
]
refund_payload = {
'refund': {
'currency': calc_data.get('currency', 'MXN'),
'notify': self.notify_customer,
'note': self.note or 'Reembolso procesado desde Odoo',
'refund_line_items': calc_line_items,
'transactions': transactions,
}
}
_logger.info("[Reembolso API] Enviando reembolso para orden Shopify %s", self.shopify_order_number)
refund_resp = requests.post(refund_url, headers=headers, json=refund_payload, timeout=20)
if refund_resp.status_code not in (200, 201):
raise UserError(
"Shopify rechazó el reembolso definitivo.\n"
"Código HTTP: %s\nDetalle: %s" % (refund_resp.status_code, refund_resp.text)
)
refund_data_final = refund_resp.json().get('refund', {})
new_refund_id = str(refund_data_final.get('id'))
_logger.info("[Reembolso API] ✅ Reembolso creado en Shopify: ID %s", new_refund_id)
# ── 7. Sincronizar en Odoo para generar Nota de Crédito y Devolución ──
try:
sync_resp = requests.get(order_url, headers=headers, timeout=15)
if sync_resp.status_code == 200:
config._import_single_shopify_order(sync_resp.json().get('order', {}))
except Exception as e_sync:
_logger.error("Error al forzar sincronización del reembolso: %s", str(e_sync))
return {
'type': 'ir.actions.client',
'tag': 'display_notification',
'params': {
'title': '✅ Reembolso Procesado con Éxito',
'message': (
f"El reembolso ID {new_refund_id} fue enviado a Shopify. "
f"Se han generado los documentos internos en Odoo."
),
'type': 'success',
'sticky': False,
'next': {
'type': 'ir.actions.act_window',
'res_model': 'sale.order',
'res_id': self.order_id.id,
'view_mode': 'form',
'views': [(False, 'form')],
'target': 'main',
}
}
}
class ShopifyRefundWizardLine(models.TransientModel):
_name = 'shopify.refund.wizard.line'
_description = 'Línea de Asistente de Reembolso'
wizard_id = fields.Many2one('shopify.refund.wizard', string='Wizard', ondelete='cascade')
product_id = fields.Many2one('product.product', string='Producto')
# Campos auxiliares para mapeo a Shopify al momento de confirmar
odoo_sku = fields.Char(string='SKU Odoo')
product_name = fields.Char(string='Nombre Producto')
# El ID de línea de Shopify se resuelve al confirmar, no al abrir
shopify_line_item_id = fields.Char(string='ID Línea Shopify')
quantity_ordered = fields.Float(string='Cant. Original')
quantity_refunded = fields.Float(string='Cant. Reembolsada')
quantity_to_refund = fields.Float(string='A Reembolsar', default=0.0)
price_unit = fields.Float(string='Precio Unitario')
@@ -0,0 +1,94 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data>
<!-- Form View of Refund Wizard -->
<record id="view_shopify_refund_wizard_form" model="ir.ui.view">
<field name="name">shopify.refund.wizard.form</field>
<field name="model">shopify.refund.wizard</field>
<field name="arch" type="xml">
<form string="🔄 Reembolso Directo en Shopify">
<sheet>
<!-- Mensaje de Advertencia de Sandbox -->
<div class="alert alert-warning text-center"
role="alert"
style="font-weight: bold; font-size: 1.05em; margin-bottom: 15px;">
🛡️ MODO SANDBOX ACTIVO: Esta acción enviará una instrucción real a Shopify.
Solo está permitido procesar pedidos con productos de prueba (SKU contiene 'PRUEBA').
Los pedidos reales de clientes están protegidos contra ejecuciones accidentales.
</div>
<group>
<group string="Datos del Pedido">
<field name="order_id" options="{'no_open': True}" readonly="1" force_save="1"/>
<field name="shopify_order_number" string="# Shopify" readonly="1" force_save="1"/>
<field name="shopify_order_id" invisible="1" force_save="1"/>
</group>
<group string="Opciones de Reembolso">
<field name="restock"/>
<field name="notify_customer"/>
<field name="amount_to_refund"
widget="monetary"
options="{'currency_field': 'currency_id'}"
style="font-weight: bold; font-size: 1.25em; color: #2C3E50;"
force_save="1"/>
</group>
</group>
<separator string="👕 Selecciona los Productos a Reembolsar" style="font-weight: bold; margin-top: 20px;"/>
<field name="line_ids" mode="tree">
<tree editable="bottom" create="false" delete="false">
<field name="product_id" readonly="1" force_save="1"/>
<field name="shopify_line_item_id" invisible="1" force_save="1"/>
<field name="quantity_ordered" readonly="1" force_save="1" string="Cant. Original" sum="Total"/>
<field name="quantity_refunded" readonly="1" force_save="1" string="Cant. Reembolsada" sum="Reembolsadas"/>
<field name="quantity_to_refund" string="A Reembolsar" class="text-primary font-weight-bold"/>
<field name="price_unit" readonly="1" force_save="1" widget="monetary" string="Precio Unitario"/>
</tree>
</field>
<group string="📝 Nota / Motivo" style="margin-top: 15px;" col="2">
<field name="note" nolabel="1" colspan="2" placeholder="Ej: Cliente canceló el pedido antes de envío, o prenda venía rota..." style="width: 100%; min-height: 80px;"/>
</group>
</sheet>
<footer>
<button name="action_confirm_refund"
string="Confirmar Reembolso en Shopify"
type="object"
class="btn-primary"
confirm="¿Realmente deseas registrar este reembolso en Shopify? Esto enviará una instrucción real de devolución de dinero a la pasarela."/>
<button string="Cancelar" class="btn-secondary" special="cancel"/>
</footer>
</form>
</field>
</record>
<!-- Window Action for Wizard -->
<record id="action_shopify_refund_wizard" model="ir.actions.act_window">
<field name="name">Reembolso Directo en Shopify</field>
<field name="res_model">shopify.refund.wizard</field>
<field name="view_mode">form</field>
<field name="target">new</field>
</record>
<!-- Extension: Botón de Reembolso en el formulario de Pedido de Venta (sale.order) -->
<record id="sale_order_shopify_refund_action_view" model="ir.ui.view">
<field name="name">sale.order.shopify.refund.button.inherit</field>
<field name="model">sale.order</field>
<field name="inherit_id" ref="sale.view_order_form"/>
<field name="arch" type="xml">
<!-- Colocar botón manual de Reembolso en el header de Odoo -->
<xpath expr="//header" position="inside">
<button name="%(action_shopify_refund_wizard)d"
string="🔄 Reembolsar en Shopify"
type="action"
class="btn-danger"
groups="hazard_shopify.group_hazard_sales,hazard_shopify.group_hazard_finance"
attrs="{'invisible': ['|', ('shopify_order_id', '=', False), ('shopify_financial_status', 'in', ('refunded', 'voided'))]}"
help="Abre el asistente para procesar un reembolso de dinero e inventario directamente en Shopify."/>
</xpath>
</field>
</record>
</data>
</odoo>