🎉 Initial commit - Hazard Odoo 16 project

- Custom modules: hazard_shopify, hazard_inventory_report, hazard_website
- Third-party modules: bi_sql_editor, query_deluxe, sql_request_abstract, l10n_mx_sat_sync_itadmin_ee
- Docker setup: Odoo 16 + PostgreSQL 17
- Utility scripts: backup_hazard.sh, update_module.py
- Agent configuration: .agents/AGENTS.md
- Security: Enterprise modules, credentials, backups and production data excluded via .gitignore
This commit is contained in:
2026-06-30 06:12:35 -06:00
commit 28d9e56f47
350 changed files with 99832 additions and 0 deletions
@@ -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>