🎉 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,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