🎉 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:
@@ -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" 📉 {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>
|
||||
Reference in New Issue
Block a user