🎉 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,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
|
||||
Reference in New Issue
Block a user