🎉 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 @@
|
||||
# -*- 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