chore: clean up historical scripts, add MCP configurations, update agent rules

This commit is contained in:
2026-07-08 14:13:58 -06:00
parent a2908167e6
commit 52470a44cb
52 changed files with 2181 additions and 2328 deletions
+268 -1
View File
@@ -52,7 +52,7 @@
- **NUNCA** usar `print()`. Siempre usar `logging` con `_logger = logging.getLogger(__name__)`. - **NUNCA** usar `print()`. Siempre usar `logging` con `_logger = logging.getLogger(__name__)`.
- Usar prefijos descriptivos en logs: `_logger.info("[Módulo] Mensaje")`. - Usar prefijos descriptivos en logs: `_logger.info("[Módulo] Mensaje")`.
- Siempre agregar docstrings a funciones y clases. - Siempre agregar docstrings a funciones y clases.
- Preferir código legible sobre código compacto.
### Despliegue de Cambios ### Despliegue de Cambios
1. **Cambios en archivos `.py` (modelos/lógica):** Requiere restart del contenedor. 1. **Cambios en archivos `.py` (modelos/lógica):** Requiere restart del contenedor.
@@ -88,3 +88,270 @@
## Conexión MCP Server PostgreSQL ## Conexión MCP Server PostgreSQL
El agente tiene acceso directo de solo lectura a la base de datos `hazard_new` a través del MCP Server configurado en `mcp_config.json`. Usar la herramienta `query` del servidor `odoo-postgres` para consultas SQL cuando sea necesario inspeccionar datos. El agente tiene acceso directo de solo lectura a la base de datos `hazard_new` a través del MCP Server configurado en `mcp_config.json`. Usar la herramienta `query` del servidor `odoo-postgres` para consultas SQL cuando sea necesario inspeccionar datos.
## Flujo Custom y Maquila (B2B)
- **Regla Crítica:** Los productos de las categorías `Insumos / Materia Prima` y `Producto Final Custom` pertenecen al flujo B2B/Maquila.
- Estos productos **NUNCA** deben enviarse ni sincronizarse con Shopify. Están tecnológicamente aislados de la plataforma de Retail.
- Cualquier script, sincronización o lógica de Odoo que interactúe con Shopify debe excluir explícitamente estas categorías.
---
## Arquitectura del Módulo `hazard_shopify`
### Modelos Custom (13 modelos)
| Modelo Técnico | Archivo | Descripción |
|---|---|---|
| `shopify.config` | `models/shopify_config.py` | Configuración central de la tienda Shopify. Contiene TODA la lógica de sincronización (~4500 líneas). |
| `shopify.sku.simulation.line` | `models/sku_simulation_line.py` | Líneas de simulación de SKU para mapeo Shopify ↔ Odoo. |
| `shopify.stock.queue` | `models/shopify_stock_queue.py` | Cola asíncrona para sincronización reactiva de inventario. |
| `shopify.cost.line` | `models/shopify_cost_line.py` | Líneas de sincronización de costos desde Shopify. |
| `shopify.publication` | `models/shopify_publication.py` | Canales de venta de Shopify (Online Store, POS, etc.). |
| `shopify.product.publication` | `models/shopify_product_publication.py` | Tabla pivote: estado de publicación de un producto por canal. |
| `shopify.location.mapping` | `models/shopify_config.py` (final) | Mapeo Sucursal Shopify ↔ Almacén/Ubicación Odoo. |
| `shopify.location.wizard` | `models/shopify_location_wizard.py` | Wizard para seleccionar sucursal de Shopify. |
| `shopify.refund.wizard` | `wizard/shopify_refund_wizard.py` | Asistente para reembolsos manuales hacia Shopify. |
### Modelos Heredados (overrides)
| Modelo | Archivo | Qué agrega |
|---|---|---|
| `product.template` | `models/product_template.py` | Campos `shopify_id`, `shopify_published`, `shopify_sync_status`, lógica de push a Shopify, canales de venta GraphQL, generación de barcodes. |
| `product.product` | `models/product_template.py` (L960+) | Campos **CRÍTICOS**: `shopify_variant_id` (llave de variante), `shopify_inventory_item_id` (llave de inventario). Auto-generación de EAN-13 al crear producto. |
| `sale.order` | `models/sale_order.py` | Campos `shopify_order_id`, `shopify_fulfillment_status`, `shopify_refund_status`, `shopify_shipping_title`. Lógica BOPIS, fulfillment, cancelación de pickings. |
| `stock.quant` | `models/stock_quant.py` | Override de `_update_available_quantity()` para encolar sincronización reactiva al cambiar stock. |
| `res.users` | `models/res_users.py` | Override de `_search()` para ocultar usuarios de soporte (`admin`, `@tesscorp.com.mx`). |
### Campos Shopify Críticos (NUNCA eliminar)
**En `product.product`:**
- `shopify_variant_id` — ID numérico de la variante en Shopify. Es la llave para push de SKU, barcodes y fulfillment.
- `shopify_inventory_item_id` — ID del ítem de inventario en Shopify. Es la llave para TODA la sincronización de stock.
**En `product.template`:**
- `shopify_id` — ID del producto (template) en Shopify.
- `shopify_published` — Flag booleano que indica si el producto existe en Shopify.
**En `sale.order`:**
- `shopify_order_id` — ID numérico del pedido en Shopify. Llave para duplicados y fulfillment.
- `shopify_shipping_title` — Título del método de envío. Se usa para detectar pedidos BOPIS (retiro en tienda).
---
## Flujos de Negocio del Conector
### Flujo 1: Importación de Pedidos (Shopify → Odoo)
```
Cron cada N min → cron_import_orders()
→ API REST: GET /orders.json?status=any&updated_at_min=...
→ Por cada pedido: _import_single_shopify_order(s_order)
→ Si ya existe: actualiza estado financiero, fulfillment, refunds, detecta ediciones
→ Si es nuevo: crea res.partner → crea sale.order → auto-confirma
→ Si POS o Fulfilled: auto-valida albarán de salida
→ Si tiene envío: agrega línea de producto ENVIO
→ Si pagado y auto_workflow_payment: publica factura + registra pago
→ Si fecha < cutoff: cancela albaranes (protección histórica)
→ Ejecuta _process_shopify_refunds_automation_logic()
```
**Reglas importantes:**
- El producto se busca por: 1° SKU (`default_code`), 2° `shopify_variant_id`, 3° nombre (`ilike`), 4° genérico `SHOPIFY-GENERIC`.
- Los impuestos se omiten (`tax_id: []`) porque Shopify ya los incluye en el precio.
- Los descuentos se calculan como porcentaje desde `discount_allocations`.
### Flujo 2: Sincronización de Inventario (Odoo → Shopify)
```
Odoo es la FUENTE DE VERDAD del stock.
Modo Reactivo (por evento):
stock.quant._update_available_quantity() → detecta cambio
→ Si producto tiene shopify_inventory_item_id → encola en shopify.stock.queue
→ Cron cada 5 min: process_queue_batch() → procesa 15 productos por lote
→ Llama a _sync_variants_stock_to_shopify()
Modo Cron (masivo periódico):
cron_sync_inventory_to_shopify() → todas las variantes con shopify_inventory_item_id
→ _sync_variants_stock_to_shopify(variants)
→ API REST: POST /inventory_levels/set.json por cada variante × ubicación
```
**Regla CRÍTICA:** Jamás invertir la dirección. El cron `cron_sync_inventory_from_shopify` está redirigido a `cron_sync_inventory_to_shopify` por seguridad operativa.
### Flujo 3: Fulfillment (Odoo → Shopify)
```
Desde el pedido en Odoo → botón "Enviar a Shopify"
→ action_notify_shopify_fulfillment()
→ Doble fallback:
1° Intento: Endpoint legacy POST /orders/{id}/fulfillments.json
2° Intento: Nuevo endpoint POST /fulfillments.json via fulfillment_orders
→ Escribe shopify_fulfillment_id y shopify_fulfillment_status en sale.order
```
### Flujo 4: BOPIS (Buy Online, Pickup In Store)
```
Detección automática por shopify_shipping_title:
Si contiene: OFICINA, RETIRO, PICKUP, SUCURSAL, TIENDA
→ Al confirmar pedido: _create_pickup_internal_transfer()
→ Crea traslado interno: Bodega ALMA → Oficina HRSGOLF
→ Reserva stock y alerta si hay faltantes
→ Al editar pedido en Shopify: _update_pickup_internal_transfer_qty()
→ Ajusta cantidades en traslados existentes o crea complementarios
```
### Flujo 5: Reembolsos y Devoluciones Automáticas
```
_process_shopify_refunds_automation_logic(sale_order, s_order)
→ Candado Sandbox: Si shopify_sandbox_mode=True, solo procesa SKUs con "PRUEBA"
→ Por cada refund no procesado:
A. Devolución de Stock: Crea stock.picking de entrada + auto-valida
B. Nota de Crédito: Crea account.move tipo out_refund en borrador
→ Registra refund_id en shopify_refund_ids_processed para idempotencia
```
**ADVERTENCIA:** Este flujo ya opera en producción. Cambios aquí impactan contabilidad real y stock físico.
### Flujo 6: Publicación de Productos (Odoo → Shopify)
```
Desde ficha de producto → botón "Publicar en Shopify"
→ action_push_to_shopify()
→ Si shopify_id existe: PUT /products/{id}.json (actualización)
→ Si no existe: POST /products.json (creación)
→ Sincroniza canales de venta vía API GraphQL (publishablePublish mutation)
→ Sube imagen si tiene (POST /products/{id}/images.json)
```
---
## Sistema de Crons (7 Acciones Planificadas)
| # | XML ID | Propósito | Frecuencia Default | Activo | Método |
|---|---|---|---|---|---|
| 1 | `ir_cron_shopify_import_orders` | Importar pedidos Shopify → Odoo | 5 min | ✅ | `cron_import_orders()` |
| 2 | `ir_cron_shopify_sync_inventory` | Stock Odoo → Shopify (masivo) | 12 horas | ❌ | `cron_sync_inventory_to_shopify()` |
| 3 | `ir_cron_shopify_refresh_tokens` | Renovar tokens OAuth (24h Shopify) | 20 horas | ✅ | `cron_refresh_tokens()` |
| 4 | `ir_cron_shopify_download_images` | Descargar imágenes (manual/batch) | 1 min | ❌ | `_cron_download_images_batch()` |
| 5 | `ir_cron_shopify_auto_image_sync` | Auto-descargar imágenes nuevas | 2 horas | ✅ | `cron_auto_download_images()` |
| 6 | `ir_cron_shopify_process_stock_queue` | Procesar cola de stock reactiva | 5 min | ✅ | `process_queue_batch()` |
| 7 | `ir_cron_shopify_clean_stock_queue` | Limpiar cola completada (+7 días) | 1 día | ✅ | `clean_done_queue()` |
**Reglas sobre Crons:**
- Los crons 1, 2 y 5 se actualizan dinámicamente desde el `write()` de `shopify.config` cuando el usuario cambia la frecuencia en la interfaz.
- **NUNCA** desactivar manualmente los crons 3 (tokens) o 6 (cola de stock) — rompe la operación.
- El cron 4 se activa/desactiva automáticamente (botón → se activa, termina → se desactiva).
---
## API de Shopify
### Configuración Técnica
- **Versión API REST:** `2024-01`
- **Versión API GraphQL:** `2024-01`
- **Autenticación:** Token de acceso (`shpat_...`) en header `X-Shopify-Access-Token`
- **Renovación de Token:** OAuth con `client_id` y `client_secret`, renovación automática cada 20h vía cron.
- **Rate Limiting:** `time.sleep(0.5)` entre requests (2 req/s). Reintentos automáticos en HTTP 429 (espera 5s, 3 intentos).
### Endpoints Usados
| Método | Endpoint | Propósito |
|---|---|---|
| GET | `/orders.json` | Importar pedidos |
| GET | `/products.json` | Importar catálogo |
| POST/PUT | `/products/{id}.json` | Crear/actualizar productos |
| POST | `/products/{id}/images.json` | Subir imágenes |
| POST | `/inventory_levels/set.json` | Sincronizar stock |
| GET | `/locations.json` | Obtener sucursales |
| POST | `/orders/{id}/fulfillments.json` | Fulfillment legacy |
| GET | `/orders/{id}/fulfillment_orders.json` | Fulfillment orders (nuevo) |
| POST | `/fulfillments.json` | Fulfillment nuevo API |
| POST | `/orders/{id}/refunds/calculate.json` | Calcular reembolso |
| POST | `/orders/{id}/refunds.json` | Ejecutar reembolso |
| PUT | `/variants/{id}.json` | Actualizar variante (SKU, barcode) |
| GET | `/inventory_levels.json` | Consultar niveles de stock |
| POST (GraphQL) | `/graphql.json` | Costos, canales de venta, publicaciones |
---
## Grupos de Seguridad del Módulo
Definidos en `security/hazard_security.xml`. Categoría: `Hazard / HRSGOLF`.
| XML ID | Nombre | Permisos | Hereda de |
|---|---|---|---|
| `group_hazard_designer` | Diseño | Crea productos, sube fotos. Sin precios ni Shopify. | `base.group_user` |
| `group_hazard_warehouse` | Bodega | Stock y tracking. Sin precios. | `stock.group_stock_user` |
| `group_hazard_sales` | Ventas B2B | Cotizaciones/pedidos. Precios solo lectura. | `sales_team.group_sale_salesman` |
| `group_hazard_finance` | Admin/Finanzas | Control total: precios, catálogo, Shopify. | `sales_team.group_sale_salesman_all_leads` |
**Regla:** Solo `group_hazard_finance` tiene acceso de escritura a `shopify.config`. Los demás tienen solo lectura o sin acceso.
---
## Cola Asíncrona de Stock (`shopify.stock.queue`)
Mecanismo reactivo para sincronizar inventario en tiempo semi-real:
1. **Trigger:** `stock.quant._update_available_quantity()` detecta cualquier cambio de stock en Odoo.
2. **Filtro:** Solo encola productos con `shopify_inventory_item_id` (vinculados a Shopify).
3. **Encolado:** Crea registro en `shopify.stock.queue` con estado `pending` (evita duplicados).
4. **Procesamiento:** Cron cada 5 min → `process_queue_batch()` → procesa 15 productos por lote.
5. **Limpieza:** Cron diario → `clean_done_queue()` → elimina registros `done` con más de 7 días.
**Condición:** Solo procesa si `auto_sync_products = True` en la configuración de Shopify.
---
## Lógica de SKU (`sku_logic.py`)
Formato estándar: `HRS-{TIPO}-{MODELO}-{GENERO}-{COLOR}-{TALLA}`
Ejemplo: `HRS-POL-CLUB-M-BLK-L` = Polo Club, Hombre, Negro, Talla L
### Diccionarios de Mapeo (hardcoded)
- **CATEGORIES:** `hat→HAT`, `polo→POL`, `hoodie→HOD`, `bag→BAG`, `glove→GLV`, `towel→TWL`, etc.
- **GENDERS:** `men→M`, `women→W`, `kids→K`. Default `U` (unisex) para accesorios.
- **COLORS:** `black→BLK`, `white→WHT`, `green→GRN`, `blue→BLU`, `red→RED`, etc.
**Regla:** NUNCA modificar estos diccionarios sin aprobación explícita del cliente. Un cambio rompe la consistencia de los SKUs existentes.
---
## Reglas de Seguridad Adicionales
### Override de Usuarios (`res_users.py`)
- El modelo `res.users` oculta los usuarios de soporte (`admin` y `@tesscorp.com.mx`) de los usuarios del cliente.
- **NUNCA eliminar este override.** Los usuarios del cliente no deben ver las cuentas de desarrollo.
### Sandbox de Reembolsos
- El campo `shopify_sandbox_mode` en `shopify.config` protege la base de datos de producción.
- Cuando está activo (por defecto), la automatización de reembolsos solo procesa pedidos con SKUs que contienen la palabra `PRUEBA`.
- **NUNCA desactivar este modo sin autorización explícita y pruebas exhaustivas.**
### Producto Genérico de Envío
- El producto con SKU `ENVIO` (tipo servicio) se usa para las líneas de costo de envío importadas de Shopify.
- Se crea automáticamente si no existe. **No eliminar.**
### Cliente Genérico POS
- El contacto `Cliente POS Shopify` se reutiliza para ventas POS sin datos de cliente.
- **No eliminar ni renombrar.** Evita crear contactos basura por cada venta POS.
### Log Persistente de Crons
- Ruta dentro del contenedor: `/var/lib/odoo/shopify_cron.log`
- Volumen Docker: `odoo-web-data-hazard-new`
- Máximo 5 MB con rotación automática.
- Para consultar: `docker exec odoo-16-hazard-new cat /var/lib/odoo/shopify_cron.log`
### Fecha de Corte de Importación
- El campo `shopify_import_cutoff_date` en `shopify.config` protege el inventario de pedidos históricos.
- Pedidos creados ANTES de esta fecha se importan pero sus albaranes de salida se cancelan automáticamente.
- **No modificar esta fecha sin entender el impacto en el stock.**
---
## Autorización de Borrado
- NUNCA elimines archivos, directorios o recursos proactivamente sin la autorización explícita y directa del usuario.
- Si el usuario pregunta "¿Puedo borrar esto?", "¿Es seguro borrar esto?" o similar, limítate EXCLUSIVAMENTE a responder a su pregunta proporcionando la información.
- NO asumas que el usuario quiere que tú ejecutes el comando de borrado. Espera a que el usuario diga explícitamente "Bórralo", "Por favor elimínalo tú", o que ejecute la acción él mismo.
## Limpieza y Scripts Temporales
- NUNCA crees scripts temporales de Python (o bash) directamente en la carpeta raíz del proyecto.
- Si necesitas crear un script (`.py`, `.sh`, etc.) para una tarea, colócalo SIEMPRE dentro del directorio `scripts/`. Crea este directorio si no existe.
+1
View File
@@ -39,6 +39,7 @@ build/
venv/ venv/
.venv/ .venv/
env/ env/
mcp_env/
# --- Logs --- # --- Logs ---
logs/ logs/
@@ -92,5 +92,37 @@
<field name="priority">8</field> <field name="priority">8</field>
</record> </record>
<!-- ================================================================
CRON 6: Procesar Cola de Sincronización de Inventario (Shopify Stock Queue)
================================================================ -->
<record id="ir_cron_shopify_process_stock_queue" model="ir.cron">
<field name="name">Shopify: Procesar Cola de Inventario</field>
<field name="model_id" ref="model_shopify_stock_queue"/>
<field name="state">code</field>
<field name="code">model.process_queue_batch()</field>
<field name="interval_number">5</field>
<field name="interval_type">minutes</field>
<field name="numbercall">-1</field>
<field name="active">True</field>
<field name="user_id" ref="base.user_root"/>
<field name="priority">5</field>
</record>
<!-- ================================================================
CRON 7: Limpiar Cola de Inventario (Shopify Stock Queue)
================================================================ -->
<record id="ir_cron_shopify_clean_stock_queue" model="ir.cron">
<field name="name">Shopify: Limpiar Cola de Inventario</field>
<field name="model_id" ref="model_shopify_stock_queue"/>
<field name="state">code</field>
<field name="code">model.clean_done_queue()</field>
<field name="interval_number">1</field>
<field name="interval_type">days</field>
<field name="numbercall">-1</field>
<field name="active">True</field>
<field name="user_id" ref="base.user_root"/>
<field name="priority">15</field>
</record>
</data> </data>
</odoo> </odoo>
@@ -9,3 +9,5 @@ from . import shopify_config
from . import res_users from . import res_users
from . import shopify_cost_line from . import shopify_cost_line
from . import product_label_layout from . import product_label_layout
from . import shopify_stock_queue
from . import stock_quant
@@ -644,48 +644,9 @@ class StockPicking(models.Model):
except Exception: except Exception:
pass # No romper el flujo si el chatter falla pass # No romper el flujo si el chatter falla
# 2. ── AUTO-STOCK SYNC (Traslados y Movimientos de Stock) ── # 2. ── AUTO-STOCK SYNC ──
products = picking.move_ids.mapped('product_id').filtered(lambda p: p.shopify_inventory_item_id) # ELIMINADO: Ahora se maneja de forma global mediante shopify.stock.queue
if products: # al sobrescribir stock.quant._update_available_quantity, lo cual
config = self.env['shopify.config'].sudo().search([('state', '=', 'confirmed')], limit=1) # cubre ajustes de inventario, ventas y traslados.
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 return res
@@ -0,0 +1,54 @@
from odoo import models, fields, api
import logging
from datetime import timedelta
_logger = logging.getLogger(__name__)
class ShopifyStockQueue(models.Model):
_name = 'shopify.stock.queue'
_description = 'Shopify Stock Sync Queue'
product_id = fields.Many2one('product.product', string='Product', required=True, ondelete='cascade')
state = fields.Selection([
('pending', 'Pendiente'),
('done', 'Realizado'),
('failed', 'Fallido')
], string='Estado', default='pending', required=True)
error_message = fields.Text(string='Mensaje de Error')
@api.model
def enqueue_product(self, product_id):
existing = self.search([
('product_id', '=', product_id),
('state', 'in', ['pending', 'failed'])
], limit=1)
if not existing:
self.create({'product_id': product_id, 'state': 'pending'})
@api.model
def process_queue_batch(self):
records = self.search([('state', 'in', ['pending', 'failed'])], limit=15)
if not records:
return
config = self.env['shopify.config'].sudo().search([('state', '=', 'confirmed')], limit=1)
if not config or not config.auto_sync_products:
return
products = records.mapped('product_id').filtered(lambda p: p.shopify_inventory_item_id)
if products:
try:
config._sync_variants_stock_to_shopify(products)
records.write({'state': 'done', 'error_message': False})
except Exception as e:
_logger.error("[Shopify Stock Queue] Falló sincronización de lote: %s", str(e))
records.write({'state': 'failed', 'error_message': str(e)})
else:
records.write({'state': 'done', 'error_message': 'No Shopify Inventory Item ID'})
@api.model
def clean_done_queue(self):
threshold_date = fields.Datetime.now() - timedelta(days=7)
old_records = self.search([('state', '=', 'done'), ('create_date', '<', threshold_date)])
if old_records:
old_records.unlink()
@@ -0,0 +1,23 @@
from odoo import models, api
class StockQuant(models.Model):
_inherit = 'stock.quant'
@api.model
def _update_available_quantity(self, product_id, location_id, quantity, lot_id=None, package_id=None, owner_id=None, in_date=None):
res = super(StockQuant, self)._update_available_quantity(
product_id=product_id,
location_id=location_id,
quantity=quantity,
lot_id=lot_id,
package_id=package_id,
owner_id=owner_id,
in_date=in_date
)
if product_id.shopify_inventory_item_id:
config = self.env['shopify.config'].sudo().search([('state', '=', 'confirmed')], limit=1)
if config and config.auto_sync_products:
self.env['shopify.stock.queue'].sudo().enqueue_product(product_id.id)
return res
-67
View File
@@ -1,67 +0,0 @@
# Documentación: Flujo Custom (B2B / Mayoreo) y Subcontratación en Odoo
*Este documento consolida las decisiones, configuraciones y la arquitectura implementada para manejar el flujo B2B y maquila, manteniéndolo 100% aislado de Shopify.*
## 1. El Problema a Resolver
El cliente (Hazard) necesita manejar un flujo donde:
- Se venden productos a la medida (ej. Camisas para Club de Golf) a clientes B2B.
- Hazard no fabrica la ropa directamente. Proveen los insumos (telas, botones, cierres) a fábricas externas (maquileras, muchas en China).
- Ocasionalmente, compran el producto ya terminado a la fábrica.
- Piden cotizaciones a diferentes fábricas y manejan anticipos.
- **Regla Crítica:** Todo este inventario (Insumos y Ropa a la Medida) debe estar separado lógicamente de la tienda en línea (Shopify), aunque físicamente compartan el almacén. El conector no debe sincronizar estos productos.
---
## 2. Solución y Arquitectura en Odoo
Para resolver esto utilizando funciones nativas de Odoo y código a prueba de errores, activamos la siguiente arquitectura:
### A. Fabricación y Subcontratación (MRP)
Se activó la **Subcontratación**.
- **Cómo funciona:** Cuando se crea la "Camisa Custom", se le asigna una **Lista de Materiales (BoM)** de tipo *Subcontratación*.
- **La magia:** Al crearle una Orden de Compra a la fábrica china por las camisas, Odoo automáticamente genera un movimiento de inventario (Resuministro de subcontratista) que **descuenta la tela de nuestro almacén principal** y la transfiere a una bodega virtual de la fábrica. Al recibir la camisa terminada, la tela se consume oficialmente.
### B. Compras y Licitaciones (Purchase Agreements)
Se activaron los **Acuerdos de Compra**.
- Esto permite crear una *Licitación* o *Requisición*. Se puede pedir cotización a 3 fábricas chinas distintas al mismo tiempo, compararlas en Odoo y elegir la ganadora para generar la Orden de Compra definitiva.
- **Anticipos:** Se resuelven registrando una factura de proveedor por "Anticipo" vinculada a la Orden de Compra, lo que crea un saldo a favor en la cuenta de la fábrica.
### C. Ventas B2B (CRM / Sales)
Se creó el **Equipo de Ventas `Custom / B2B`**.
- Los vendedores usan Odoo (recomendado usar el CRM Kanban de Odoo en lugar de Notion para evitar doble trabajo) para hacer las cotizaciones (Quotations) directamente.
---
## 3. El Blindaje contra Shopify (Código Personalizado)
Para garantizar que el inventario no se mezcle con Shopify, se crearon dos categorías de producto exclusivas:
1. `Insumos / Materia Prima`
2. `Producto Final Custom`
**La medida de seguridad:**
Se modificó el módulo custom `hazard_shopify` (específicamente en `models/product_template.py`). Se añadió un `@api.constrains` que actúa como escudo protector:
```python
@api.constrains('shopify_published', 'categ_id')
def _check_shopify_published_category(self):
for record in self:
if record.shopify_published and record.categ_id:
if 'Custom' in record.categ_id.name or 'Insumo' in record.categ_id.name:
raise UserError("Los productos de la categoría Custom o Insumos no pueden ser publicados en Shopify.")
```
**Resultado:** Incluso si un usuario intenta forzar la sincronización (marcando la casilla "Publicado en Shopify" o haciendo clic en el botón de push), el sistema lanzará un error y bloqueará la acción. La separación está garantizada por código.
*(Nota: Los códigos de barra automáticos EAN13 siguen generándose para estos productos gracias a la lógica del sistema, lo cual es útil para escaneos internos en bodega).*
---
## 4. Guía de Prueba Rápida (Walkthrough)
Se inyectaron datos de prueba (`setup_test_data.py`) para validar el flujo. Los pasos para una demo son:
1. **Cotizar al Cliente Final:** Confirmar la Cotización (SO) para el cliente `Club de Golf Test` por 5 unidades de `Camisa Custom Club de Golf (Test)`.
2. **Mandar a Maquilar:** Confirmar la Orden de Compra (PO) a `Fábrica China Test`.
3. **Validar Envío de Insumo:** Ir a *Inventario > Operaciones > Resuministro de Subcontratista* y validar la entrega de 7.5 metros de `Tela Azul Premium` hacia la fábrica.
4. **Recibir el Producto Final:** Volver a la Orden de Compra y recibir las 5 camisas.
5. **Entregar al Cliente Final:** Volver a la Cotización original (SO) y validar la entrega al cliente.
*Durante todo este flujo, Shopify permanece 100% aislado.*
-117
View File
@@ -1,117 +0,0 @@
# Análisis de Implementación Odoo: Fase de Lanzamiento (Hazard)
Este análisis técnico y operativo evalúa las tareas restantes para llevar a producción la implementación de Odoo para el cliente **Hazard** (Hazard Rough Society), estimando los esfuerzos, dependencias y tiempos requeridos.
---
## 🏁 ¿Dónde Estamos? (Lo que ya está listo)
El núcleo de la integración técnica Shopify-Odoo está desarrollado y robustecido en el módulo **`hazard_shopify`**:
1. **Conexión Segura (OAuth):** Gestión automática y renovación de tokens cada 24 horas.
2. **Detección de Duplicados (Paso 0):** Filtro preventivo de SKUs repetidos en Shopify con propuesta de corrección en Odoo.
3. **Generación e Inyección de EAN-13:** Códigos secuenciales GS1 e inyección en background (multihilo) a Shopify respetando el *rate limit*.
4. **Sincronización de Stock Multi-Sucursal:** Mapeo de almacenes Odoo ↔ Sucursales Shopify y sincronización de cantidades basada en discrepancias.
5. **Importación Automatizada de Catálogo e Imágenes:** Creación automática de variantes, descarga de fotos y vinculación de IDs.
6. **Fulfillment Sync (Logística):** Envío manual del estado de entrega y número de guía desde Odoo a Shopify.
7. **Exportación de Reportes Avanzados:** Hojas de cálculo y CSVs formateados para impresión de etiquetas e inventarios.
8. **Diseño de Landing Page (`hazard_website`):** Página de inicio con diseño premium y minimalista.
---
## ⚠️ Lo que Falta para la Implementación Completa (Go-Live ERP)
Aunque la sincronización con Shopify es completamente funcional, una implementación de Odoo lista para el cliente final requiere configurar los procesos del *core* del ERP: Contabilidad fiscal mexicana, operaciones logísticas automatizadas, conciliación bancaria y migración final.
A continuación, se detalla la hoja de ruta pendiente dividida en 6 ejes de trabajo:
```mermaid
gantt
title Cronograma Estimado de Implementación Odoo
dateFormat YYYY-MM-DD
section Eje 1: Localización MX
SAT CFDI 4.0 & Facturación :active, des1, 2026-05-21, 5d
section Eje 2: Logística
Integración de Paqueteras : des2, after des1, 4d
section Eje 3: Flujos ERP
Automatización de Ventas y Compras : des3, after des2, 3d
section Eje 4: Finanzas
Conciliación y Pasarelas : des4, after des3, 3d
section Eje 5: Datos
Migración Histórica e Inventario : des5, after des4, 4d
section Eje 6: Lanzamiento
Pruebas (UAT) y Capacitación : des6, after des5, 5d
Soporte Go-Live : des7, after des6, 3d
```
---
### Eje 1: Localización Fiscal Mexicana (SAT - CFDI 4.0) 🇲🇽
El cliente opera bajo dominio mexicano (`.com.mx`) y cuenta con el módulo `l10n_mx_sat_sync_itadmin_ee`. Es indispensable asegurar que todas las transacciones cumplan con la ley mexicana.
- [ ] **Configuración de la Localización:** Carga del Plan de Cuentas del SAT, régimen fiscal de la empresa, impuestos activos (IVA 16%, retenciones si aplica) y certificados de sello digital (CSD) para el timbrado.
- [ ] **Clasificación SAT de Productos:** Configurar las Claves de Producto/Servicio y Claves de Unidad del SAT en la base de datos de productos de Odoo.
- [ ] **Automatización del Timbrado:** Configurar las reglas de facturación automática: al confirmar un pedido de Shopify en Odoo, la factura de cliente debe generarse, timbrarse electrónicamente (CFDI 4.0) mediante el PAC contratado, y enviarse por correo de forma transparente.
- **Esfuerzo Estimado:** 4 a 6 días hábiles.
### Eje 2: Integración Logística Automatizada (DHL, FedEx, Estafeta, etc.) 📦
Actualmente, el número de guía se puede escribir a mano para que se suba a Shopify. En producción, esto debe ser automático.
- [ ] **Configuración de Transportistas en Odoo:** Instalar e integrar los conectores de envío (ej: módulo de DHL Express o FedEx para Odoo).
- [ ] **Generación de Etiquetas en Un Clic:** Configurar el sistema para que, al validar un albarán de salida en el inventario, Odoo llame a la API de la paquetera, obtenga la etiqueta en formato PDF, la mande a la impresora y registre automáticamente el número de guía (*tracking number*).
- [ ] **Fulfillment Automatizado:** Configurar un disparador en Odoo para que al momento de timbrar el albarán como `Listo/Enviado` y recibir la guía, se invoque el método `action_notify_shopify_fulfillment` automáticamente sin que el operador tenga que presionar un botón manual.
- **Esfuerzo Estimado:** 3 a 5 días hábiles.
### Eje 3: Flujos de Operación ERP y Abastecimiento (Ventas, Inventarios, Compras) 🔄
Configurar el comportamiento del motor interno del ERP.
- [ ] **Reglas de Abastecimiento Automático:** Configurar niveles mínimos y máximos de stock por almacén (puntos de pedido). Si una variante baja de cierta cantidad en Odoo, el sistema debe generar un borrador de Orden de Compra (RFQ) al proveedor automáticamente.
- [ ] **Rutas de Inventario Complejas:** En caso de tener un almacén central de distribución y tiendas físicas (o almacenes virtuales de Shopify), configurar las rutas de reabastecimiento interno (*Internal Transfers*) entre almacenes.
- [ ] **Workflow de Shopify simplificado:** Definir si los pedidos bajan como "Presupuestos" o se confirman directamente como "Órdenes de Venta" según el estado del pago en Shopify.
- **Esfuerzo Estimado:** 3 a 4 días hábiles.
### Eje 4: Conciliación Financiera y Pasarelas de Pago 💳
- [ ] **Pasarelas de Pago Mapeadas:** Conciliar los pagos que ingresan por Shopify (Shopify Payments, PayPal, Mercado Pago, etc.) contra las cuentas puente de Odoo.
- [ ] **Conciliación Bancaria:** Configurar la sincronización de extractos bancarios en Odoo para que el departamento de finanzas pueda conciliar con un clic los depósitos reales del banco contra los pagos registrados de las facturas de Shopify.
- **Esfuerzo Estimado:** 3 a 4 días hábiles.
### Eje 5: Migración de Datos Final e Inventario Inicial 📊
- [ ] **Clientes y Proveedores:** Carga limpia del histórico de clientes y proveedores desde sistemas legados u hojas de cálculo.
- [ ] **Inventario Físico Inicial:** Carga del inventario inicial real valorizado en Odoo (conteo físico de almacén) y su posterior sincronización masiva a Shopify para igualar números por primera vez.
- **Esfuerzo Estimado:** 3 a 5 días hábiles.
### Eje 6: Pruebas de Calidad (UAT), Capacitación y Go-Live 🎯
- [ ] **Pruebas de Aceptación (UAT):** Realizar simulaciones completas de extremo a extremo:
1. Compra de un cliente en Shopify.
2. Descarga del pedido y creación de factura timbrada SAT en Odoo.
3. Generación de orden de salida de almacén, cotización/etiqueta con paquetera, y empaque.
4. Envío automático del código de rastreo a Shopify.
5. Conciliación bancaria al recibir el depósito.
- [ ] **Capacitación:** Sesiones de capacitación grabadas para los operadores de almacén (uso del inventario), administradores de ventas y contadores.
- [ ] **Soporte de Salida a Producción (Go-Live):** Acompañamiento los primeros 3 días de operación real para resolver dudas o problemas de red inmediatos.
- **Esfuerzo Estimado:** 7 a 9 días hábiles (incluye capacitación y soporte post-lanzamiento).
---
## 📅 Resumen de Tiempos y Planificación Financiera de Esfuerzos
A continuación se presenta el desglose estimado de tiempos necesarios para entregar el ERP completo y funcional al cliente.
| Eje de Trabajo | Duración Estimada | Recursos Clave Requeridos |
| :--- | :---: | :--- |
| **Eje 1: Localización SAT CFDI 4.0** | 4 - 6 Días | Consultor Funcional + Contador del Cliente |
| **Eje 2: Integración de Paqueteras** | 3 - 5 Días | Desarrollador Odoo + Credenciales de Paquetera (DHL/FedEx) |
| **Eje 3: Flujos de Operación ERP** | 3 - 4 Días | Consultor Funcional + Líder de Operaciones |
| **Eje 4: Pasarelas y Conciliación** | 3 - 4 Días | Consultor Financiero + Tesorería del Cliente |
| **Eje 5: Migración de Datos** | 3 - 5 Días | Administrador de Base de Datos + Hojas Excel limpias |
| **Eje 6: Capacitación y Go-Live** | 7 - 9 Días | Todo el Equipo Operativo y de Soporte |
| **TOTAL ESTIMADO** | **23 - 33 Días Hábiles** | **~4.5 a 6 Semanas de Calendario** |
> [!IMPORTANT]
> **Dependencias Críticas:**
> 1. **Certificados SAT (CSD):** El cliente debe entregar los CSD vigentes y contraseña para habilitar el timbrado de facturas de inmediato.
> 2. **APIs de Paqueteras:** La obtención de llaves de desarrollo y producción para DHL/FedEx suele tardar de 3 a 5 días por parte de los ejecutivos de cuenta de las paqueteras. Es vital solicitar esto con anticipación.
> 3. **Datos de Inventario:** El cliente debe comprometerse a realizar un corte de inventario físico un fin de semana previo a la fecha de Go-Live.
---
### Recomendación de Siguientes Pasos
Para iniciar esta fase de cierre de proyecto, sugerimos presentar este reporte al cliente y programar una sesión de alineación para:
1. Validar la prioridad de los transportistas a integrar.
2. Coordinar con su equipo contable la entrega de accesos para la facturación.
3. Definir la fecha objetivo de Go-Live (se recomienda a mediados de mes para facilitar el cierre contable).
-15
View File
@@ -1,15 +0,0 @@
# -*- coding: utf-8 -*-
import datetime
from odoo import api, SUPERUSER_ID
def bypass_license(env):
ICP = env['ir.config_parameter'].sudo()
ICP.set_param('database.expiration_date', '2030-01-01 00:00:00')
ICP.set_param('database.expiration_reason', 'renewal')
ICP.set_param('database.enterprise_code', 'odoo-enterprise-2030')
ICP.set_param('publisher_warranty.maintenance_disable', True)
print("License parameters updated to 2030.")
if __name__ == "__main__":
# This part is for running via odoo-bin shell
bypass_license(env)
-39
View File
@@ -1,39 +0,0 @@
#!/usr/bin/env python3
import xmlrpc.client
import sys
import logging
ODOO_URL = 'http://localhost:16001'
ODOO_DB = 'hazard_new'
ODOO_USER = 'admin'
ODOO_PASS = 'admin'
def main():
common = xmlrpc.client.ServerProxy(f'{ODOO_URL}/xmlrpc/2/common')
uid = common.authenticate(ODOO_DB, ODOO_USER, ODOO_PASS, {})
if not uid:
print("No se pudo autenticar")
return
models = xmlrpc.client.ServerProxy(f'{ODOO_URL}/xmlrpc/2/object')
print("Consultando productos en Odoo...")
products = models.execute_kw(ODOO_DB, uid, ODOO_PASS,
'product.product', 'search_read',
[[('barcode', '!=', False)]],
{'fields': ['display_name', 'default_code', 'barcode']}
)
wrong_barcodes = []
for p in products:
if not p['barcode'].startswith('200001'):
wrong_barcodes.append(p)
print(f"\nSe encontraron {len(wrong_barcodes)} productos con un código de barras que NO empieza con 200001:\n")
for p in wrong_barcodes:
print(f"Producto: {p['display_name']}")
print(f"SKU: {p['default_code']}")
print(f"Barcode: {p['barcode']}")
print("-" * 40)
if __name__ == '__main__':
main()
-32
View File
@@ -1,32 +0,0 @@
#!/usr/bin/env python3
import xmlrpc.client
import sys
ODOO_URL = 'http://localhost:16001'
ODOO_DB = 'hazard_new'
ODOO_USER = 'admin'
ODOO_PASS = 'admin'
def main():
common = xmlrpc.client.ServerProxy(f'{ODOO_URL}/xmlrpc/2/common')
uid = common.authenticate(ODOO_DB, ODOO_USER, ODOO_PASS, {})
models = xmlrpc.client.ServerProxy(f'{ODOO_URL}/xmlrpc/2/object')
print("Consultando TODOS los productos (activos e inactivos) en Odoo...")
products = models.execute_kw(ODOO_DB, uid, ODOO_PASS,
'product.product', 'search_read',
[['|', ('active', '=', True), ('active', '=', False), ('barcode', '!=', False)]],
{'fields': ['display_name', 'default_code', 'barcode', 'active']}
)
wrong_barcodes = []
for p in products:
if not p['barcode'].startswith('200001'):
wrong_barcodes.append(p)
print(f"\nSe encontraron {len(wrong_barcodes)} productos con barcode que NO empieza con 200001:\n")
for p in wrong_barcodes:
print(f"[{'ACTIVO' if p['active'] else 'INACTIVO'}] Producto: {p['display_name']} | SKU: {p['default_code']} | Barcode: {p['barcode']}")
if __name__ == '__main__':
main()
-13
View File
@@ -1,13 +0,0 @@
import os
import sys
# Script para extraer la configuración de Shopify directamente de Odoo
def get_shopify_config():
# Simular entorno Odoo si fuera necesario, pero mejor leemos el archivo de config o usamos env vars
# Por ahora, intentaré listar los registros de shopify.config
pass
# Como no puedo acceder fácilmente al entorno Odoo desde fuera sin pasar por rpc o shell,
# voy a intentar encontrar el archivo de configuración de Odoo para ver el nombre de la DB.
# Típicamente está en /etc/odoo/odoo.conf o similar.
-20
View File
@@ -1,20 +0,0 @@
import xmlrpc.client
url = 'http://localhost:16001'
db = 'hazard_new'
username = 'admin'
password = '123'
try:
common = xmlrpc.client.ServerProxy('{}/xmlrpc/2/common'.format(url))
uid = common.authenticate(db, username, 'admin', {})
if not uid:
uid = common.authenticate(db, 'admin', '123', {})
models = xmlrpc.client.ServerProxy('{}/xmlrpc/2/object'.format(url))
logs = models.execute_kw(db, uid, 'admin', 'ir.logging', 'search_read', [[['message', 'ilike', 'Shopify']]], {'fields': ['create_date', 'name', 'level', 'message'], 'order': 'id desc', 'limit': 20})
for l in logs:
print(f"[{l['create_date']}] {l['level']} - {l['message']}")
except Exception as e:
print("Error:", e)
-21
View File
@@ -1,21 +0,0 @@
import psycopg2
import json
conn = psycopg2.connect(dbname="hazard_new", user="odoo", password="odoo", host="localhost", port="5432")
cur = conn.cursor()
ref = 'HRS-GLV-CABRET-U-WHT-L'
cur.execute("""
SELECT pt.id, pt.name, pp.id, pp.default_code
FROM product_product pp
JOIN product_template pt ON pp.product_tmpl_id = pt.id
WHERE pp.default_code = %s
""", (ref,))
for row in cur.fetchall():
print("Template ID:", row[0])
print("Template Name (JSONb):", row[1])
print("Product ID:", row[2])
cur.close()
conn.close()
-26
View File
@@ -1,26 +0,0 @@
import xmlrpc.client
url = 'http://localhost:16001'
db = 'hazard_new'
username = 'admin'
password = '123'
common = xmlrpc.client.ServerProxy('{}/xmlrpc/2/common'.format(url))
uid = common.authenticate(db, username, 'admin', {})
if not uid: uid = common.authenticate(db, 'admin', '123', {})
models = xmlrpc.client.ServerProxy('{}/xmlrpc/2/object'.format(url))
ref = 'HRS-GLV-CABRET-U-WHT-L'
for lang in ['en_US', 'es_MX', 'es_ES']:
products = models.execute_kw(db, uid, 'admin', 'product.product', 'search_read',
[[['default_code', '=', ref]]],
{'fields': ['id', 'name', 'display_name'], 'context': {'lang': lang}})
if products:
print(f"Lang {lang}: {products[0]['name']}")
moves = models.execute_kw(db, uid, 'admin', 'stock.move', 'search_read',
[[['product_id', '=', products[0]['id']]]],
{'fields': ['id', 'name', 'description_picking'], 'limit': 1, 'order': 'id desc'})
if moves:
print(f"Move Lang {lang}: {moves[0]['description_picking']}")
-55
View File
@@ -1,55 +0,0 @@
import xmlrpc.client
import sys
url = 'http://localhost:16001'
db = 'hazard_new'
username = 'admin'
password = '123'
try:
common = xmlrpc.client.ServerProxy('{}/xmlrpc/2/common'.format(url))
uid = common.authenticate(db, username, 'admin', {})
if not uid:
uid = common.authenticate(db, 'admin', '123', {})
models = xmlrpc.client.ServerProxy('{}/xmlrpc/2/object'.format(url))
ref = 'HRS-GLV-CABRET-U-WHT-L'
products = models.execute_kw(db, uid, 'admin', 'product.product', 'search_read',
[[['default_code', '=', ref]]],
{'fields': ['id', 'name', 'display_name', 'product_tmpl_id', 'active']})
if not products:
print(f"Producto {ref} no encontrado.")
sys.exit()
print(f"--- SE ENCONTRARON {len(products)} PRODUCTOS CON ESE SKU ---")
for product in products:
tmpl_id = product['product_tmpl_id'][0]
print(f"\nVariante ID: {product['id']} | Activo: {product['active']}")
print(f"Name (variante): {product['name']}")
print(f"Display Name: {product['display_name']}")
tmpls = models.execute_kw(db, uid, 'admin', 'product.template', 'read',
[[tmpl_id]],
{'fields': ['id', 'name', 'active']})
print(f"Template Name: {tmpls[0]['name']} | Activo: {tmpls[0]['active']}")
# 3. Buscar las órdenes más recientes de este producto
print(" --- ULTIMAS LINEAS DE VENTA ---")
sols = models.execute_kw(db, uid, 'admin', 'sale.order.line', 'search_read',
[[['product_id', '=', product['id']]]],
{'fields': ['id', 'order_id', 'name', 'create_date'], 'limit': 3, 'order': 'id desc'})
for sol in sols:
print(f" SOL ID: {sol['id']} | Fecha: {sol['create_date']} | Pedido: {sol['order_id'][1]} | Descripcion: {sol['name']}")
print(" --- ULTIMOS MOVIMIENTOS DE INVENTARIO ---")
moves = models.execute_kw(db, uid, 'admin', 'stock.move', 'search_read',
[[['product_id', '=', product['id']]]],
{'fields': ['id', 'picking_id', 'name', 'description_picking', 'create_date'], 'limit': 3, 'order': 'id desc'})
for m in moves:
picking_name = m['picking_id'][1] if m['picking_id'] else 'Sin Albaran'
print(f" Move ID: {m['id']} | Fecha: {m['create_date']} | Albaran: {picking_name} | Desc: {m['description_picking']}")
except Exception as e:
print("Error conectando o consultando Odoo:", e)
-79
View File
@@ -1,79 +0,0 @@
#!/usr/bin/env python3
import xmlrpc.client
import requests
import sys
ODOO_URL = 'http://localhost:16001'
ODOO_DB = 'hazard_new'
ODOO_USER = 'admin'
ODOO_PASS = 'admin'
def main():
common = xmlrpc.client.ServerProxy(f'{ODOO_URL}/xmlrpc/2/common')
uid = common.authenticate(ODOO_DB, ODOO_USER, ODOO_PASS, {})
models = xmlrpc.client.ServerProxy(f'{ODOO_URL}/xmlrpc/2/object')
print("Obteniendo credenciales de Shopify desde Odoo...")
config_ids = models.execute_kw(ODOO_DB, uid, ODOO_PASS, 'shopify.config', 'search', [[]])
if not config_ids:
print("No se encontró configuración de shopify.")
return
config = models.execute_kw(ODOO_DB, uid, ODOO_PASS, 'shopify.config', 'read', [config_ids[0]], {'fields': ['shop_url', 'access_token']})[0]
shop_url = config.get('shop_url')
access_token = config.get('access_token')
if not shop_url or not access_token:
print("Faltan credenciales de Shopify.")
return
base_url = f"https://{shop_url}/admin/api/2023-10"
headers = {
'X-Shopify-Access-Token': access_token,
'Content-Type': 'application/json',
}
print(f"Conectando a Shopify ({shop_url})... esto puede tomar un poco dependiendo del número de productos.")
url = f"{base_url}/products.json?limit=250"
wrong_barcodes = []
while url:
resp = requests.get(url, headers=headers)
if resp.status_code != 200:
print(f"Error consultando Shopify: {resp.status_code} {resp.text}")
break
data = resp.json()
products = data.get('products', [])
for p in products:
for v in p.get('variants', []):
barcode = v.get('barcode')
if barcode and not barcode.startswith('200001'):
wrong_barcodes.append({
'product_title': p.get('title'),
'variant_title': v.get('title'),
'sku': v.get('sku'),
'barcode': barcode,
'id': v.get('id')
})
# Paginación (Link header)
link_header = resp.headers.get('Link')
url = None
if link_header:
links = link_header.split(',')
for link in links:
if 'rel="next"' in link:
url = link[link.find('<')+1 : link.find('>')]
break
print(f"\nSe encontraron {len(wrong_barcodes)} variantes en Shopify con un código de barras que NO empieza con 200001:\n")
for b in wrong_barcodes:
print(f"Producto: {b['product_title']} - {b['variant_title']}")
print(f"SKU: {b['sku']} | Barcode: {b['barcode']} | Variant ID: {b['id']}")
if __name__ == '__main__':
main()
-26
View File
@@ -1,26 +0,0 @@
#!/usr/bin/env python3
import xmlrpc.client
ODOO_URL = 'http://localhost:16001'
ODOO_DB = 'hazard_new'
ODOO_USER = 'admin'
ODOO_PASS = 'admin'
def main():
common = xmlrpc.client.ServerProxy(f'{ODOO_URL}/xmlrpc/2/common')
uid = common.authenticate(ODOO_DB, ODOO_USER, ODOO_PASS, {})
models = xmlrpc.client.ServerProxy(f'{ODOO_URL}/xmlrpc/2/object')
print("Buscando productos vinculados a Shopify con stock 0 en Odoo...")
products = models.execute_kw(ODOO_DB, uid, ODOO_PASS,
'product.product', 'search_read',
[[('shopify_inventory_item_id', '!=', False), ('qty_available', '<=', 0)]],
{'fields': ['display_name', 'default_code', 'qty_available']}
)
print(f"\nSe encontraron {len(products)} variantes vinculadas a Shopify que en Odoo tienen 0 (o menos) de stock:\n")
for p in products:
print(f"[{p['default_code']}] {p['display_name']} -> Stock Odoo: {p['qty_available']}")
if __name__ == '__main__':
main()
-53
View File
@@ -1,53 +0,0 @@
import requests
# Datos de conexión (basados en lo que vi en el código)
shop_url = "hazard-mexico.myshopify.com"
token = "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" # Tomado de mis registros previos o se asume correcto
headers = {'X-Shopify-Access-Token': token}
sku = "HRS-POL-BLUKNT-M-WHT-S"
# Primero buscamos la variante para obtener el inventory_item_id
search_url = f"https://{shop_url}/admin/api/2024-01/products.json?handle=blue-knit-collar-white-polo" # Aproximado por el título en la imagen
# O mejor buscamos por SKU usando el endpoint de variantes si fuera posible, pero probemos buscando el producto.
# En realidad, como no tengo el ID de la variante a mano, voy a buscarla por SKU en el endpoint de búsqueda de productos si existe o listando.
print(f"--- DIAGNÓSTICO PARA SKU: {sku} ---")
# Intentemos obtener todas las ubicaciones primero para ver qué IDs tienen
loc_url = f"https://{shop_url}/admin/api/2024-01/locations.json"
resp_loc = requests.get(loc_url, headers=headers)
locations = resp_loc.json().get('locations', [])
print("\nUBICACIONES EN SHOPIFY:")
for l in locations:
print(f"ID: {l['id']} - Nombre: {l['name']}")
# Ahora buscamos el producto para obtener el inventory_item_id del SKU
# Como no sé el ID del producto, voy a buscar en los primeros productos
prod_url = f"https://{shop_url}/admin/api/2024-01/products.json?limit=50"
resp_prod = requests.get(prod_url, headers=headers)
products = resp_prod.json().get('products', [])
target_variant = None
for p in products:
for v in p['variants']:
if v['sku'] == sku:
target_variant = v
break
if target_variant: break
if target_variant:
inv_item_id = target_variant['inventory_item_id']
print(f"\nVARIANTE ENCONTRADA: {target_variant['id']}")
print(f"Inventory Item ID: {inv_item_id}")
# Ahora pedimos los niveles de inventario
inv_url = f"https://{shop_url}/admin/api/2024-01/inventory_levels.json?inventory_item_ids={inv_item_id}"
resp_inv = requests.get(inv_url, headers=headers)
levels = resp_inv.json().get('inventory_levels', [])
print("\nNIVELES DE INVENTARIO PARA ESTE ITEM:")
for lvl in levels:
print(f"Location ID: {lvl['location_id']} - Disponible: {lvl['available']}")
else:
print("\nNo se encontró la variante por SKU en los primeros 50 productos.")
-77
View File
@@ -1,77 +0,0 @@
import requests
import json
# Datos de conexión obtenidos de la configuración de Odoo
shop_url = "hazard-mexico.myshopify.com"
token = "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"
headers = {'X-Shopify-Access-Token': token}
# SKU a investigar
sku_to_check = "HRS-POL-BLUKNT-M-WHT-S"
print(f"--- INVESTIGACIÓN DE STOCK PARA SKU: {sku_to_check} ---")
# 1. Obtener ubicaciones para ver qué tenemos configurado en Shopify
loc_url = f"https://{shop_url}/admin/api/2024-01/locations.json"
r_loc = requests.get(loc_url, headers=headers)
print(f"Status Ubicaciones: {r_loc.status_code}")
locations = r_loc.json().get('locations', [])
for l in locations:
print(f"Ubicación: {l['name']} (ID: {l['id']})")
# 2. Buscar el producto por SKU usando GraphQL (más directo)
query = """
{
productVariants(first: 1, query: "sku:%s") {
edges {
node {
id
legacyResourceId
inventoryItem {
id
legacyResourceId
inventoryLevels(first: 10) {
edges {
node {
quantities(names: ["available"]) {
name
quantity
}
location {
id
name
}
}
}
}
}
}
}
}
}
""" % sku_to_check
gql_url = f"https://{shop_url}/admin/api/2024-01/graphql.json"
r_gql = requests.post(gql_url, headers=headers, json={'query': query})
if r_gql.status_code == 200:
data = r_gql.json()
variants = data.get('data', {}).get('productVariants', {}).get('edges', [])
if not variants:
print(f"\nNo se encontró ninguna variante con SKU {sku_to_check} via GraphQL.")
else:
var_node = variants[0]['node']
inv_item = var_node['inventoryItem']
print(f"\nVariante encontrada en Shopify!")
print(f"Variant ID: {var_node['legacyResourceId']}")
print(f"Inventory Item ID: {inv_item['legacyResourceId']}")
print("\nNiveles de inventario reportados por la API:")
for edge in inv_item['inventoryLevels']['edges']:
loc = edge['node']['location']
qties = edge['node']['quantities']
qty = qties[0]['quantity'] if qties else 0
print(f"- {loc['name']} (ID: {loc['id'].split('/')[-1]}): {qty}")
else:
print(f"Error en GraphQL: {r_gql.status_code} - {r_gql.text}")
+2
View File
@@ -2,6 +2,7 @@ services:
db: db:
image: postgres:17 image: postgres:17
container_name: odoo-16-db-hazard-new container_name: odoo-16-db-hazard-new
restart: always
environment: environment:
POSTGRES_USER: odoo POSTGRES_USER: odoo
POSTGRES_PASSWORD: odoo POSTGRES_PASSWORD: odoo
@@ -12,6 +13,7 @@ services:
odoo: odoo:
build: . build: .
container_name: odoo-16-hazard-new container_name: odoo-16-hazard-new
restart: always
depends_on: depends_on:
- db - db
ports: ports:
@@ -0,0 +1,120 @@
# Manual Operativo Definitivo: Flujo Custom y Maquila (B2B)
Este manual detalla paso a paso cómo debe operar el personal de la empresa (Ventas, Compras y Almacén) para vender productos a la medida maquilados por terceros, garantizando que **nada de esto interfiera con Shopify**.
---
## FASE 0: El Blindaje contra Shopify (Para tu tranquilidad)
**¿Cómo evitamos que se mezcle con Shopify?**
Implementamos un "candado" a nivel de código en tu Odoo. Creamos dos categorías de inventario intocables por Shopify:
1. `Insumos / Materia Prima`
2. `Producto Final Custom`
Si alguien por error intenta presionar el botón "Publicar en Shopify" en cualquier producto de estas categorías, **Odoo lanzará un error bloqueando la acción**. La plataforma de Retail (Shopify) está tecnológicamente aislada de este flujo B2B.
---
## FASE 1: Creación de Insumos y Productos (Responsable: Compras / Almacén)
Antes de vender, hay que decirle a Odoo qué materiales tenemos, cómo se enviarán a la fábrica y qué vamos a vender.
### 1.1 Crear el Insumo (Ej. La Tela o Botones)
1. Ir al módulo de **Inventario > Productos > Crear**.
2. **Nombre:** `Tela Azul Premium` o `Botones`
3. **Tipo de Producto:** `Almacenable` (para poder contar las existencias).
4. **Categoría de Producto:** `Insumos / Materia Prima` *(¡CRÍTICO para el blindaje!)*.
5. **Configuración de Rutas y Proveedores (¡Muy Importante!):**
Aquí debes elegir cómo llegará este insumo a la fábrica maquiladora. Tienes dos escenarios:
* **Escenario A: El insumo sale de nuestro almacén (Flujo Normal)**
* Ve a la pestaña **Inventario** y en la sección de Rutas marca: **"Reabastecer al subcontratista al ordenar"**.
* *(Esto le dirá a Odoo que nos pida sacar la mercancía de nuestra bodega).*
* **Escenario B: El proveedor de tela la envía directo a la fábrica (Triangulación / Dropshipping)**
* Ve a la pestaña **Inventario** y en Rutas marca: **"Enviar al subcontratista al ordenar"** (Dropship Subcontractor).
* Ve a la pestaña **Compras** y agrega al proveedor que te vende esta tela.
* *(Esto le dirá a Odoo que genere una compra automática a ese proveedor enviando la tela directo a la maquila).*
6. Guardar.
### 1.2 Crear el Producto Final (Ej. La Camisa)
1. Ir a **Inventario > Productos > Crear**.
2. **Nombre:** `Camisa Custom Club de Golf`
3. **Tipo de Producto:** `Almacenable`.
4. **Categoría de Producto:** `Producto Final Custom` *(¡CRÍTICO!)*.
5. Ir a la pestaña **Compra**: Haz clic en *Agregar una línea* y pon el nombre de la fábrica que la va a maquilar (ej. *Fábrica China*) y el costo que te cobran por mano de obra.
6. Guardar.
### 1.3 Unir el Insumo y la Camisa (La Lista de Materiales / BoM)
Odoo necesita saber qué se usa para fabricar la camisa.
1. Dentro de la pantalla de la `Camisa Custom`, haz clic en el botón superior que dice **Lista de Materiales (BoM)** y dale a **Crear**.
2. **Tipo de BoM:** Selecciona **Subcontratación** *(Subcontracting)*.
3. **Subcontratista:** Selecciona a la *Fábrica China*.
4. **Componentes:** Abajo, agrega la `Tela Azul Premium` y los `Botones`, indicando la cantidad necesaria para 1 sola camisa.
5. Guardar.
---
## FASE 2: La Venta (Responsable: Ventas B2B)
El cliente corporativo acepta trabajar con nosotros.
> [!TIP]
> **Precios Tabulados (Descuentos por Volumen)**
> Si el precio al cliente cambia a partir de cierta cantidad (ej. si compran más de 50 camisas el precio baja), Ventas puede configurar esto automáticamente:
> 1. Ir a **Ventas > Productos > Listas de Precios**.
> 2. Crear una regla para la `Camisa Custom`, definiendo la "Cantidad Mínima" (ej. 50) y el nuevo precio. Al hacer la cotización, Odoo ajustará el precio solo si se supera esa cantidad.
1. Ir al módulo de **Ventas > Crear Cotización**.
2. **Cliente:** `Club de Golf`
3. Agregar el producto `Camisa Custom Club de Golf` y poner la cantidad (ej. 50 camisas).
4. Hacer clic en **Confirmar**.
> **Resultado:** Se genera la orden de salida del almacén (`ALMA/OUT`), pero se queda en estado **"En Espera"** porque aún no existen las camisas físicas.
---
## FASE 3: Mandar a Maquilar (Responsable: Compras)
Ventas ya vendió, ahora Compras debe pedirle a la fábrica que trabaje.
1. Ir al módulo de **Compras > Crear**.
2. **Proveedor:** Seleccionar `Fábrica China`.
3. **Producto:** Agregar `Camisa Custom Club de Golf` (50 camisas).
4. Hacer clic en **Confirmar Orden**.
> **Nota sobre Anticipos:** Si la fábrica pide un adelanto, en esta misma pantalla de Compras puedes darle a *Crear Factura* (Factura de Anticipo) para que Finanzas pague.
---
## FASE 4: Abastecer a la Fábrica de Insumos
Al confirmar el Paso 3, Odoo automáticamente prepara los materiales para la fábrica, dependiendo del escenario que configuramos en la Fase 1:
* **Si usamos el Escenario A (Salió de nuestro almacén):**
1. El responsable va a **Inventario**. Aparecerá una tarjeta llamada **Resuministro de Subcontratista**.
2. Al entrar, Almacén verá una orden pidiendo enviar los botones y la tela.
3. El bodeguero se los da a paquetería y en Odoo le da clic a **Validar**.
* **Si usamos el Escenario B (Triangulación directa del proveedor a la fábrica):**
1. Odoo creó una **segunda Orden de Compra en estado Borrador** mágicamente.
2. El de Compras va al módulo de **Compras** y verá esta nueva orden a nombre del proveedor de telas. Las cantidades estarán calculadas automáticamente (ej. 50 camisas x 1.5m = 75m) y la *Dirección de Entrega* será la Fábrica China.
3. El de Compras solo entra y le da clic a **Confirmar**. ¡Listo!
---
## FASE 5: Recepción de Camisas Terminadas (Responsable: Almacén)
Llegan las camisas listas.
1. Ir a **Inventario > Recepciones (`ALMA/IN`)**.
2. Abrir la recepción vinculada a la orden de la Fábrica China.
3. Contar las camisas, poner la cantidad recibida y dar clic en **Validar**.
> **El Costeo Automático:** Odoo acaba de consumir la tela y los botones, y le sumó el costo de la mano de obra. Todo está asignado al costo de la camisa terminada.
> [!NOTE]
> **Añadir Costos Extras (Costos en Destino / Landed Costs)**
> ¿Qué pasa si pagaste un flete de última hora, aduanas, o un planchado externo y quieres que eso sume al valor de las camisas?
> 1. Ve a **Inventario > Operaciones > Costos en Destino**.
> 2. Crea uno nuevo, ingresa el monto de la factura del flete y selecciona el movimiento de Recepción de las camisas (el que acabas de validar).
> 3. Al confirmar, Odoo tomará ese gasto extra y lo repartirá equitativamente entre las 50 camisas, aumentando su valor contable.
---
## FASE 6: Entrega al Cliente Final (Responsable: Almacén / Finanzas)
Como ya tenemos camisas, la orden de Ventas se desbloquea.
1. Ir a **Inventario > Órdenes de Entrega (`ALMA/OUT`)**.
2. La orden del *Club de Golf* (Fase 2) que estaba "En Espera", ahora dirá **"Listo"**.
3. El empacador saca las camisas para el cliente y le da clic a **Validar**.
4. Finalmente, Finanzas entra a la orden de Venta original y da clic en **Crear Factura** para el cliente. ¡Fin del proceso!
-87
View File
@@ -1,87 +0,0 @@
# Propuesta de Implementación: Fase 1 - MVP Retail y Logística (Hazard)
Esta propuesta técnica detalla cómo estructurar una salida a producción rápida y segura en **Fase 1**, enfocándose exclusivamente en el **flujo retail y logística de envíos**, posponiendo la facturación fiscal SAT y conciliación contable avanzada para una **Fase 2**.
---
## 💡 ¿Es viable facturar "por fuera" de Odoo?
**Totalmente, y de hecho es una estrategia sumamente recomendada para acelerar el Go-Live.**
En Odoo, que la facturación se realice externamente (mediante portales, sistemas contables heredados como Aspel/Contpaqi o facturación directa en el SAT) **no genera ningún problema técnico ni operativo**.
### ¿Cómo se gestiona esto en Odoo?
1. **Flujo Sin Factura Fiscal:** Odoo procesará el pedido (`sale.order`) y la orden de entrega (`stock.picking`) para rebajar el stock e inyectar la guía a Shopify. La transacción se queda registrada a nivel comercial/operativo.
2. **Facturas Internas en Odoo (Opcional - Recomendado):** Se pueden generar las facturas dentro de Odoo para tener reportes de ventas y cuentas por cobrar internos, pero **sin timbrarlas fiscalmente** (sin llamar a la API del SAT ni consumir timbres fiscales). Esto permite llevar una pre-contabilidad en Odoo mientras la facturación oficial sigue en su sistema habitual.
3. **Reducción de Riesgos:** Al remover la dependencia de los certificados digitales (CSD), códigos SAT y validaciones de impuestos del SAT, eliminamos el Eje 1 por completo, eliminando bloqueos técnicos y permitiendo una puesta en marcha inmediata.
---
## 🎯 Enfoque de Fase 1: El Flujo Retail MVP
Para esta primera etapa, el objetivo prioritario es que **la operación de venta en línea y despacho físico funcione como un reloj**.
### 🔄 El Flujo Crítico de Fase 1:
```
[Compra Cliente en Shopify]
[Odoo: Descarga Automática de Pedido (Sale Order)]
[Odoo Almacén: Se genera Orden de Salida (Stock Picking)]
[Odoo Logística: Generación de Guía DHL/FedEx y Etiqueta PDF]
[Odoo: Validación de Salida e Inyección de Guía a Shopify (Fulfillment)]
[Shopify: Envía correo con rastreo al cliente final]
```
---
## 📅 Cronograma Reducido: Fase 1 (MVP Retail & Logística)
Al concentrarnos únicamente en el flujo retail y operativo, **el tiempo de implementación se reduce a la mitad**:
```mermaid
gantt
title Cronograma Fase 1 - MVP Retail y Logística
dateFormat YYYY-MM-DD
section Eje 1: Logística
Integración DHL/FedEx/Estafeta :active, des1, 2026-05-21, 4d
section Eje 2: Flujo ERP
Workflow Ventas & Almacén : des2, after des1, 3d
section Eje 3: Datos
Inventario Inicial Real : des3, after des2, 2d
section Eje 4: Capacitación
UAT & Capacitación Operativa : des4, after des3, 4d
Acompañamiento Go-Live : des5, after des4, 2d
```
### Detalle de Tareas - Fase 1:
| Tarea de Fase 1 | Duración | Descripción / Alcance |
| :--- | :---: | :--- |
| **1. Integración Logística** | **3 - 4 días** | Configuración de conectores DHL/FedEx en Odoo para obtener tarifas, guías y PDFs de etiquetas automáticas. |
| **2. Flujo de Pedidos y Almacén** | **2 - 3 días** | Configuración del flujo de pedidos de venta: cuando el pedido de Shopify se descargue pagado, Odoo lo confirma y genera el albarán de salida en estado "Listo para procesar" de inmediato. |
| **3. Carga de Inventario Inicial** | **2 días** | Corte físico de inventario. Carga inicial del stock en Odoo y sincronización de cantidades masivas a Shopify para alinear ambos sistemas por primera vez. |
| **4. Pruebas y Capacitación** | **3 - 4 días** | Simulaciones reales de compra y envío con el equipo de almacén y ventas. Capacitación práctica de despacho y picking. |
| **5. Soporte en Lanzamiento** | **2 días** | Acompañamiento operativo los primeros días de envíos en vivo. |
| **TOTAL ESTIMADO FASE 1** | **12 - 15 días hábiles** | **~2.5 a 3 Semanas de Calendario** |
---
## 🚀 Plan de Crecimiento (Fases Posteriores)
Una vez que la bodega y la tienda en línea estén sincronizadas y operando sin fricción, se pueden ir agregando los demás módulos de manera controlada:
### 📅 Fase 2: Contabilidad e Impuestos (A los 1-2 meses del Go-Live)
* **Localización SAT Mexicana:** Configuración de impuestos IVA, retenciones, cuentas contables oficiales y CSD para timbrado.
* **Timbrado de Facturas:** Habilitación del timbrado CFDI 4.0 directamente en Odoo, ya sea automático al importar el pedido o en lotes al final del día.
* **Conciliación de Pasarelas:** Integración del flujo contable de depósitos bancarios de Mercado Pago, PayPal y Shopify Payments.
### 📅 Fase 3: Abastecimiento Inteligente (A los 3-4 meses)
* **Reglas de Reordenamiento:** Puntos de stock mínimo para generar borradores de órdenes de compra (RFQ) de forma automática.
* **Costeo y Margen:** Carga de costos reales de compra/importación para reportes exactos de rentabilidad y valuación de inventario en tiempo real.
-83
View File
@@ -1,83 +0,0 @@
import csv
from collections import defaultdict
import xmlrpc.client
import requests
ODOO_URL = 'http://localhost:16001'
ODOO_DB = 'hazard_new'
ODOO_USER = 'admin'
ODOO_PASS = 'admin'
def get_shopify_credentials():
common = xmlrpc.client.ServerProxy(f'{ODOO_URL}/xmlrpc/2/common')
uid = common.authenticate(ODOO_DB, ODOO_USER, ODOO_PASS, {})
models = xmlrpc.client.ServerProxy(f'{ODOO_URL}/xmlrpc/2/object')
config_ids = models.execute_kw(ODOO_DB, uid, ODOO_PASS, 'shopify.config', 'search', [[]])
config = models.execute_kw(ODOO_DB, uid, ODOO_PASS, 'shopify.config', 'read', [config_ids[0]], {'fields': ['shop_url', 'access_token']})[0]
return config.get('shop_url'), config.get('access_token')
def get_padel_skus():
shop_url, access_token = get_shopify_credentials()
query = """
{
collection(id: "gid://shopify/Collection/487990952129") {
products(first: 250) {
edges { node { legacyResourceId } }
}
}
}
"""
url = f"https://{shop_url}/admin/api/2023-10/graphql.json"
headers = {'X-Shopify-Access-Token': access_token, 'Content-Type': 'application/json'}
resp = requests.post(url, headers=headers, json={'query': query})
product_ids = [str(edge['node']['legacyResourceId']) for edge in resp.json()['data']['collection']['products']['edges']]
skus = set()
url_rest = f"https://{shop_url}/admin/api/2023-10/products.json"
for pid in product_ids:
r = requests.get(f"{url_rest}?ids={pid}", headers={'X-Shopify-Access-Token': access_token})
for p in r.json().get('products', []):
for v in p.get('variants', []):
skus.add(v.get('sku'))
return skus
padel_skus = get_padel_skus()
filename = 'orders_export_1 (8)(1).csv'
total_gross = 0
total_net = 0
channels = defaultdict(float)
draft_count = 0
web_count = 0
pos_count = 0
with open(filename, 'r', encoding='utf-8-sig') as f:
reader = csv.DictReader(f)
for row in reader:
sku = row.get('Lineitem sku', '')
if sku in padel_skus:
# count channels
source = row.get('Source', 'unknown')
if 'draft' in source: draft_count += 1
elif 'web' in source: web_count += 1
elif 'pos' in source: pos_count += 1
if row.get('Financial Status') in ['paid', 'partially_refunded', 'refunded']:
price = float(row.get('Lineitem price') or 0)
qty = int(row.get('Lineitem quantity') or 0)
discount = float(row.get('Lineitem discount') or 0)
gross = price * qty
net = gross - discount # CSV doesn't clearly show line-item refunds
total_gross += gross
total_net += net
channels[source] += net
print(f"--- ANALISIS DEL CSV (Pedidos Exportados) ---")
print(f"Total Gross (sin descuento): {total_gross}")
print(f"Total Net (con descuento, sin reembolso real): {total_net}")
print("Por canal en el CSV:")
for c, v in channels.items():
print(f" {c}: {v}")
print(f"Apariciones por canal: Web={web_count}, POS={pos_count}, Draft={draft_count}")
-25
View File
@@ -1,25 +0,0 @@
import csv
from collections import defaultdict
filename = 'orders_export_1 (8)(1).csv'
total_lineitem_price = 0
total_with_qty = 0
total_with_discount = 0
with open(filename, 'r', encoding='utf-8-sig') as f:
reader = csv.DictReader(f)
for row in reader:
name = row.get('Lineitem name', '').upper()
if 'CLUB 78' in name or 'PADEL 78' in name or 'PADEL' in name:
price = float(row.get('Lineitem price') or 0)
qty = int(row.get('Lineitem quantity') or 0)
discount = float(row.get('Lineitem discount') or 0)
total_lineitem_price += price
total_with_qty += (price * qty)
total_with_discount += (price * qty) - discount
print(f"Total price only: {total_lineitem_price}")
print(f"Total with qty: {total_with_qty}")
print(f"Total with discount: {total_with_discount}")
-340
View File
@@ -1,340 +0,0 @@
#!/usr/bin/env python3
"""
Script para corregir capas de valoración (SVL) faltantes en Odoo 16.
=======================================================================
PROBLEMA: Productos con stock en almacén y costo definido, pero sin capas
de valoración (stock.valuation.layer), lo que causa que el listado de
existencias muestre Costo Unitario = $0.00 y Valor Total = $0.00.
QUÉ HACE:
- Identifica productos con stock > 0, costo > 0, pero sin SVL
- Crea una capa de valoración inicial para cada uno
- NO modifica cantidades, stock.quant, stock.move ni contabilidad
QUÉ NO HACE:
- NO toca cantidades de inventario
- NO crea movimientos de stock
- NO genera asientos contables
- NO modifica productos existentes
USO:
python3 fix_svl_missing.py --dry-run # Solo muestra lo que haría
python3 fix_svl_missing.py --apply # Aplica la corrección
python3 fix_svl_missing.py --verify # Verifica después de aplicar
"""
import xmlrpc.client
import sys
import logging
from datetime import datetime
# ─── Configuración ────────────────────────────────────────────────────────
ODOO_URL = 'http://localhost:8069'
ODOO_DB = 'hazard_new'
ODOO_USER = 'admin'
ODOO_PASS = 'admin'
COMPANY_ID = 1
# ─── Logging ──────────────────────────────────────────────────────────────
log_filename = f"/tmp/fix_svl_{datetime.now().strftime('%Y%m%d_%H%M%S')}.log"
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s [%(levelname)s] %(message)s',
handlers=[
logging.FileHandler(log_filename),
logging.StreamHandler(sys.stdout)
]
)
logger = logging.getLogger(__name__)
def connect():
"""Establece conexión XML-RPC con Odoo."""
common = xmlrpc.client.ServerProxy(f'{ODOO_URL}/xmlrpc/2/common')
uid = common.authenticate(ODOO_DB, ODOO_USER, ODOO_PASS, {})
if not uid:
raise ConnectionError("No se pudo autenticar con Odoo")
models = xmlrpc.client.ServerProxy(f'{ODOO_URL}/xmlrpc/2/object')
logger.info("Conectado a Odoo (uid=%s, db=%s)", uid, ODOO_DB)
return uid, models
def find_affected_products(uid, models):
"""
Encuentra productos que tienen:
1. stock.quant con quantity > 0 en ubicación interna
2. standard_price > 0
3. NO tienen stock.valuation.layer
Retorna lista de dicts con product_id, qty, standard_price, quant_id.
"""
logger.info("─── Buscando productos afectados ───")
# Paso 1: Obtener todos los quants con stock positivo en ubicaciones internas
quant_ids = models.execute_kw(ODOO_DB, uid, ODOO_PASS,
'stock.quant', 'search',
[[['quantity', '>', 0], ['location_id.usage', '=', 'internal']]])
quants = models.execute_kw(ODOO_DB, uid, ODOO_PASS,
'stock.quant', 'read', [quant_ids],
{'fields': ['product_id', 'quantity', 'location_id', 'company_id']})
logger.info("Quants con stock positivo en almacén: %d", len(quants))
# Paso 2: Agrupar por product_id (sumar qty si hay múltiples quants)
product_quants = {}
for q in quants:
pid = q['product_id'][0]
if pid not in product_quants:
product_quants[pid] = {
'product_id': pid,
'product_name': q['product_id'][1],
'total_qty': 0.0,
'company_id': q['company_id'][0] if q['company_id'] else COMPANY_ID,
'quant_ids': []
}
product_quants[pid]['total_qty'] += q['quantity']
product_quants[pid]['quant_ids'].append(q['id'])
logger.info("Productos únicos con stock: %d", len(product_quants))
# Paso 3: Filtrar los que NO tienen SVL
all_product_ids = list(product_quants.keys())
svl_product_ids_raw = models.execute_kw(ODOO_DB, uid, ODOO_PASS,
'stock.valuation.layer', 'search_read',
[[['product_id', 'in', all_product_ids]]],
{'fields': ['product_id'], 'limit': 0})
products_with_svl = set(s['product_id'][0] for s in svl_product_ids_raw)
products_without_svl = {pid: data for pid, data in product_quants.items()
if pid not in products_with_svl}
logger.info("Productos SIN SVL: %d", len(products_without_svl))
# Paso 4: Obtener standard_price y filtrar los que tienen costo > 0
if not products_without_svl:
return []
pids_to_check = list(products_without_svl.keys())
# Leer en lotes de 200 para evitar timeout
affected = []
batch_size = 200
for i in range(0, len(pids_to_check), batch_size):
batch = pids_to_check[i:i+batch_size]
prods = models.execute_kw(ODOO_DB, uid, ODOO_PASS,
'product.product', 'read', [batch],
{'fields': ['standard_price', 'default_code', 'display_name',
'cost_method', 'categ_id']})
for p in prods:
if p['standard_price'] and p['standard_price'] > 0:
info = products_without_svl[p['id']]
info['standard_price'] = p['standard_price']
info['default_code'] = p.get('default_code') or ''
info['display_name'] = p.get('display_name') or ''
info['cost_method'] = p.get('cost_method') or 'standard'
info['categ_id'] = p.get('categ_id', [False, ''])[0]
affected.append(info)
logger.info("Productos con costo > 0 pero SIN SVL: %d", len(affected))
return affected
def dry_run(affected):
"""Muestra lo que haría sin modificar nada."""
logger.info("")
logger.info("═══════════════════════════════════════════════════")
logger.info(" DRY RUN - No se modificará nada")
logger.info("═══════════════════════════════════════════════════")
logger.info("")
total_value = 0.0
for i, item in enumerate(affected, 1):
value = item['total_qty'] * item['standard_price']
total_value += value
logger.info(
"[%3d] %-50s | Qty: %6.2f | Costo: $%10.2f | Valor: $%10.2f",
i,
item.get('default_code') or item.get('display_name', '')[:50],
item['total_qty'],
item['standard_price'],
value
)
logger.info("")
logger.info("─── RESUMEN ───")
logger.info("Productos a corregir: %d", len(affected))
logger.info("Valor total faltante: $%.2f", total_value)
logger.info("")
logger.info("Para aplicar la corrección ejecutar con --apply")
def apply_fix(uid, models, affected):
"""
Crea las capas de valoración faltantes.
Para cada producto afectado, crea UN registro en stock.valuation.layer
que representa la entrada inicial del inventario con su costo correcto.
"""
logger.info("")
logger.info("═══════════════════════════════════════════════════")
logger.info(" APLICANDO CORRECCIÓN")
logger.info("═══════════════════════════════════════════════════")
logger.info("")
success_count = 0
error_count = 0
total_value_created = 0.0
for i, item in enumerate(affected, 1):
pid = item['product_id']
qty = item['total_qty']
cost = item['standard_price']
value = qty * cost
try:
# Crear la capa de valoración
# Estos campos replican lo que Odoo crea en un ajuste de inventario
svl_vals = {
'product_id': pid,
'company_id': item['company_id'],
'quantity': qty,
'unit_cost': cost,
'value': value,
'remaining_qty': qty,
'remaining_value': value,
'description': 'Corrección de valoración - inventario inicial sin capa',
}
svl_id = models.execute_kw(ODOO_DB, uid, ODOO_PASS,
'stock.valuation.layer', 'create', [svl_vals])
success_count += 1
total_value_created += value
if i % 25 == 0 or i <= 5:
logger.info(
"[%3d/%d] ✅ SVL #%d creado para %s (qty=%.2f, costo=$%.2f, valor=$%.2f)",
i, len(affected), svl_id,
item.get('default_code') or str(pid),
qty, cost, value
)
except Exception as e:
error_count += 1
logger.error(
"[%3d/%d] ❌ Error en producto %s (id=%d): %s",
i, len(affected),
item.get('default_code') or item.get('display_name', ''),
pid, str(e)
)
logger.info("")
logger.info("─── RESULTADO ───")
logger.info("Exitosos: %d", success_count)
logger.info("Errores: %d", error_count)
logger.info("Valor total creado: $%.2f", total_value_created)
logger.info("")
return success_count, error_count
def verify(uid, models):
"""Verifica que la corrección funcionó comparando antes/después."""
logger.info("")
logger.info("═══════════════════════════════════════════════════")
logger.info(" VERIFICACIÓN POST-CORRECCIÓN")
logger.info("═══════════════════════════════════════════════════")
logger.info("")
# 1. Verificar el OVERGRIP BLANCO (caso original)
test_ids = [9364, 9365, 9366]
for pid in test_ids:
prod = models.execute_kw(ODOO_DB, uid, ODOO_PASS,
'product.product', 'read', [pid],
{'fields': ['display_name', 'standard_price', 'quantity_svl', 'value_svl']})
if prod:
p = prod[0]
logger.info(
"Producto %-50s | standard_price=$%.2f | quantity_svl=%.2f | value_svl=$%.2f",
p['display_name'][:50], p['standard_price'],
p['quantity_svl'], p['value_svl']
)
quants = models.execute_kw(ODOO_DB, uid, ODOO_PASS,
'stock.quant', 'search_read',
[[['product_id', '=', pid], ['location_id.usage', '=', 'internal']]],
{'fields': ['quantity', 'value']})
for q in quants:
logger.info(
" └─ Quant: qty=%.2f, value=$%.2f %s",
q['quantity'], q['value'],
'' if q['value'] > 0 else '❌ AÚN EN CERO'
)
# 2. Contar cuántos quants siguen en value=0
zero_count = models.execute_kw(ODOO_DB, uid, ODOO_PASS,
'stock.quant', 'search_count',
[[['quantity', '>', 0], ['value', '=', 0], ['location_id.usage', '=', 'internal']]])
total_count = models.execute_kw(ODOO_DB, uid, ODOO_PASS,
'stock.quant', 'search_count',
[[['quantity', '>', 0], ['location_id.usage', '=', 'internal']]])
logger.info("")
logger.info("─── ESTADO GENERAL ───")
logger.info("Quants con stock en almacén: %d", total_count)
logger.info("Quants con value = 0 (problema): %d", zero_count)
logger.info("Quants con value > 0 (OK): %d", total_count - zero_count)
def main():
if len(sys.argv) < 2 or sys.argv[1] not in ('--dry-run', '--apply', '--verify'):
print(__doc__)
print("Uso:")
print(" python3 fix_svl_missing.py --dry-run # Solo muestra lo que haría")
print(" python3 fix_svl_missing.py --apply # Aplica la corrección")
print(" python3 fix_svl_missing.py --verify # Verifica después de aplicar")
sys.exit(1)
mode = sys.argv[1]
logger.info("Modo: %s", mode)
logger.info("Log: %s", log_filename)
uid, models_proxy = connect()
if mode == '--verify':
verify(uid, models_proxy)
return
affected = find_affected_products(uid, models_proxy)
if not affected:
logger.info("✅ No se encontraron productos afectados. No hay nada que corregir.")
return
if mode == '--dry-run':
dry_run(affected)
elif mode == '--apply':
logger.info("")
logger.info("⚠️ CONFIRMACIÓN: Se crearán %d capas de valoración.", len(affected))
logger.info(" Respaldo previo: backups/tmp/hazard_new_pre_svl_fix_*.dump")
logger.info("")
success, errors = apply_fix(uid, models_proxy, affected)
if errors == 0:
logger.info("✅ Corrección completada sin errores.")
else:
logger.warning("⚠️ Corrección completada con %d errores. Revisar log.", errors)
# Verificación automática post-aplicación
logger.info("")
logger.info("Ejecutando verificación automática...")
verify(uid, models_proxy)
if __name__ == '__main__':
main()
-194
View File
@@ -1,194 +0,0 @@
#!/usr/bin/env python3
"""
Script de corrección puntual para el pedido S00651.
====================================================
PROBLEMA: El pedido S00651 fue editado en Shopify (GRIS TALAVERA FMG HAT: 5 → 6).
La orden de venta y la orden de entrega se actualizaron, pero el traslado interno
ALMA/INT/00083 ya estaba validado con 5 unidades.
SOLUCIÓN: Crear un traslado interno complementario de +1 unidad del ALMACÉN → OFICINA.
USO:
python3 fix_transfer_s00651.py --dry-run # Solo muestra lo que haría
python3 fix_transfer_s00651.py --apply # Crea el traslado complementario
"""
import xmlrpc.client
import sys
import logging
from datetime import datetime
# ─── Configuración ────────────────────────────────────────────────────────
ODOO_URL = 'http://localhost:16001'
ODOO_DB = 'hazard_new'
ODOO_USER = 'admin'
ODOO_PASS = 'admin'
# ─── Datos del pedido afectado ────────────────────────────────────────────
SALE_ORDER_NAME = 'S00651'
PRODUCT_SKU = 'HRS-HAT-FMG-U-GRY-OS' # GRIS TALAVERA FMG HAT
QTY_FALTANTE = 1 # Diferencia: 6 - 5 = 1
# ─── Logging ──────────────────────────────────────────────────────────────
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s [%(levelname)s] %(message)s',
handlers=[logging.StreamHandler(sys.stdout)]
)
logger = logging.getLogger(__name__)
def connect():
common = xmlrpc.client.ServerProxy(f'{ODOO_URL}/xmlrpc/2/common')
uid = common.authenticate(ODOO_DB, ODOO_USER, ODOO_PASS, {})
if not uid:
raise ConnectionError("No se pudo autenticar con Odoo")
models = xmlrpc.client.ServerProxy(f'{ODOO_URL}/xmlrpc/2/object')
logger.info("Conectado a Odoo (uid=%s)", uid)
return uid, models
def main():
if len(sys.argv) < 2 or sys.argv[1] not in ('--dry-run', '--apply'):
print(__doc__)
sys.exit(1)
mode = sys.argv[1]
uid, models = connect()
# 1. Buscar el pedido S00651
sale_ids = models.execute_kw(ODOO_DB, uid, ODOO_PASS,
'sale.order', 'search', [[('name', '=', SALE_ORDER_NAME)]])
if not sale_ids:
logger.error("No se encontró el pedido %s", SALE_ORDER_NAME)
sys.exit(1)
sale_id = sale_ids[0]
sale_records = models.execute_kw(ODOO_DB, uid, ODOO_PASS,
'sale.order', 'read', [sale_id],
{'fields': ['name', 'state', 'warehouse_id']})
sale = sale_records[0]
logger.info("Pedido encontrado: %s (ID=%s, Estado=%s)", sale['name'], sale_id, sale['state'])
# 2. Buscar el producto GRIS TALAVERA
product_ids = models.execute_kw(ODOO_DB, uid, ODOO_PASS,
'product.product', 'search', [[('default_code', '=', PRODUCT_SKU)]])
if not product_ids:
logger.error("No se encontró el producto con SKU %s", PRODUCT_SKU)
sys.exit(1)
product_id = product_ids[0]
product_records = models.execute_kw(ODOO_DB, uid, ODOO_PASS,
'product.product', 'read', [product_id],
{'fields': ['name', 'display_name', 'default_code']})
product = product_records[0]
logger.info("Producto encontrado: %s (ID=%s)", product['display_name'], product_id)
# 3. Buscar el traslado interno original (ALMA/INT/00083)
internal_move_ids = models.execute_kw(ODOO_DB, uid, ODOO_PASS,
'stock.move', 'search', [[
'|', ('picking_id.sale_id', '=', sale_id), ('picking_id.origin', '=', SALE_ORDER_NAME),
('picking_id.picking_type_id.code', '=', 'internal'),
('product_id', '=', product_id),
('state', '=', 'done'),
]])
if not internal_move_ids:
logger.error("No se encontró movimiento interno completado para este producto en este pedido")
sys.exit(1)
move_records = models.execute_kw(ODOO_DB, uid, ODOO_PASS,
'stock.move', 'read', [internal_move_ids[0]],
{'fields': ['picking_id', 'product_uom_qty', 'product_uom', 'location_id', 'location_dest_id', 'warehouse_id']})
move_data = move_records[0]
picking_id = move_data['picking_id'][0]
picking_records = models.execute_kw(ODOO_DB, uid, ODOO_PASS,
'stock.picking', 'read', [picking_id],
{'fields': ['name', 'state', 'picking_type_id', 'location_id', 'location_dest_id']})
picking = picking_records[0]
logger.info("Traslado original: %s (Estado=%s, Cantidad=%s)", picking['name'], picking['state'], move_data['product_uom_qty'])
# 4. Verificar la orden de entrega
delivery_move_ids = models.execute_kw(ODOO_DB, uid, ODOO_PASS,
'stock.move', 'search', [[
'|', ('picking_id.sale_id', '=', sale_id), ('picking_id.origin', '=', SALE_ORDER_NAME),
('picking_id.picking_type_id.code', '=', 'outgoing'),
('product_id', '=', product_id),
('state', '!=', 'cancel'),
]])
if delivery_move_ids:
del_move_records = models.execute_kw(ODOO_DB, uid, ODOO_PASS,
'stock.move', 'read', [delivery_move_ids[0]],
{'fields': ['product_uom_qty', 'picking_id']})
del_move = del_move_records[0]
del_picking_records = models.execute_kw(ODOO_DB, uid, ODOO_PASS,
'stock.picking', 'read', [del_move['picking_id'][0]],
{'fields': ['name', 'state']})
del_picking = del_picking_records[0]
logger.info("Orden de entrega: %s (Estado=%s, Demanda=%s)", del_picking['name'], del_picking['state'], del_move['product_uom_qty'])
logger.info("")
logger.info("═══════════════════════════════════════════════════")
logger.info(" RESUMEN DE CORRECCIÓN")
logger.info("═══════════════════════════════════════════════════")
logger.info(" Pedido: %s", SALE_ORDER_NAME)
logger.info(" Producto: %s (%s)", product['display_name'], PRODUCT_SKU)
logger.info(" Traslado: %s (ya completado con %s unidades)", picking['name'], int(move_data['product_uom_qty']))
logger.info(" Faltante: +%s unidad(es)", QTY_FALTANTE)
logger.info(" Acción: Crear traslado complementario ALMA → OFICINA")
logger.info("═══════════════════════════════════════════════════")
if mode == '--dry-run':
logger.info("")
logger.info(" 🔍 DRY RUN - No se modificó nada")
logger.info(" Para aplicar ejecutar: python3 fix_transfer_s00651.py --apply")
return
# 5. Crear traslado complementario
logger.info("")
logger.info("Creando traslado complementario...")
new_picking_id = models.execute_kw(ODOO_DB, uid, ODOO_PASS,
'stock.picking', 'create', [{
'picking_type_id': picking['picking_type_id'][0],
'location_id': picking['location_id'][0],
'location_dest_id': picking['location_dest_id'][0],
'origin': SALE_ORDER_NAME,
'sale_id': sale_id,
'move_type': 'direct',
}])
new_move_id = models.execute_kw(ODOO_DB, uid, ODOO_PASS,
'stock.move', 'create', [{
'name': f"[Complemento S00651] {product['display_name']}",
'product_id': product_id,
'product_uom_qty': QTY_FALTANTE,
'product_uom': move_data['product_uom'][0],
'picking_id': new_picking_id,
'location_id': picking['location_id'][0],
'location_dest_id': picking['location_dest_id'][0],
'warehouse_id': move_data['warehouse_id'][0] if move_data['warehouse_id'] else False,
}])
# Confirmar y reservar
models.execute_kw(ODOO_DB, uid, ODOO_PASS,
'stock.picking', 'action_confirm', [[new_picking_id]])
models.execute_kw(ODOO_DB, uid, ODOO_PASS,
'stock.picking', 'action_assign', [[new_picking_id]])
# Leer el nombre del nuevo traslado
new_picking = models.execute_kw(ODOO_DB, uid, ODOO_PASS,
'stock.picking', 'read', [new_picking_id],
{'fields': ['name', 'state']})
logger.info("")
logger.info("✅ Traslado complementario creado: %s (Estado: %s)", new_picking['name'], new_picking['state'])
logger.info(" Producto: %s", product['display_name'])
logger.info(" Cantidad: +%s unidad(es)", QTY_FALTANTE)
logger.info(" Ruta: ALMA/Stock → OFFIC/Existencias")
logger.info("")
logger.info("⚡ SIGUIENTE PASO: Notifica a almacén para que valide el traslado %s", new_picking['name'])
if __name__ == '__main__':
main()
-8
View File
@@ -1,8 +0,0 @@
import xmlrpc.client
url = 'http://localhost:8069'
db = 'hazard_new'
username = 'admin'
password = '123'
# We don't know the admin password for XML-RPC, let's use odoo environment directly
-33
View File
@@ -1,33 +0,0 @@
#!/usr/bin/env python3
import xmlrpc.client
import os
ODOO_URL = 'http://localhost:16001'
ODOO_DB = 'hazard_new'
ODOO_USER = 'admin'
ODOO_PASS = 'admin'
def main():
common = xmlrpc.client.ServerProxy(f'{ODOO_URL}/xmlrpc/2/common')
uid = common.authenticate(ODOO_DB, ODOO_USER, ODOO_PASS, {})
models = xmlrpc.client.ServerProxy(f'{ODOO_URL}/xmlrpc/2/object')
products = models.execute_kw(ODOO_DB, uid, ODOO_PASS,
'product.product', 'search_read',
[[('shopify_inventory_item_id', '!=', False), ('qty_available', '<=', 0)]],
{'fields': ['display_name', 'default_code', 'qty_available'], 'order': 'default_code ASC'}
)
out_file = '/root/.gemini/antigravity-ide/brain/4ef721a3-6674-4055-9705-38e237353959/productos_stock_cero.md'
with open(out_file, 'w', encoding='utf-8') as f:
f.write("# Productos que Odoo reporta con Stock 0 en Shopify\n\n")
f.write("A continuación se listan las variantes que tienen stock 0 en Odoo y están vinculadas a Shopify. Debido a que Odoo es la Fuente de Verdad, el sistema envía este **0** a Shopify cada vez que se ejecuta la sincronización de inventario.\n\n")
f.write("| SKU | Producto | Stock Odoo |\n")
f.write("|---|---|---|\n")
for p in products:
f.write(f"| `{p['default_code']}` | {p['display_name']} | {p['qty_available']} |\n")
print(f"Reporte generado en {out_file} con {len(products)} variantes.")
if __name__ == '__main__':
main()
-92
View File
@@ -1,92 +0,0 @@
import csv
import json
from collections import defaultdict
import xmlrpc.client
import requests
ODOO_URL = 'http://localhost:16001'
ODOO_DB = 'hazard_new'
ODOO_USER = 'admin'
ODOO_PASS = 'admin'
def get_shopify_credentials():
common = xmlrpc.client.ServerProxy(f'{ODOO_URL}/xmlrpc/2/common')
uid = common.authenticate(ODOO_DB, ODOO_USER, ODOO_PASS, {})
models = xmlrpc.client.ServerProxy(f'{ODOO_URL}/xmlrpc/2/object')
config_ids = models.execute_kw(ODOO_DB, uid, ODOO_PASS, 'shopify.config', 'search', [[]])
config = models.execute_kw(ODOO_DB, uid, ODOO_PASS, 'shopify.config', 'read', [config_ids[0]], {'fields': ['shop_url', 'access_token']})[0]
return config.get('shop_url'), config.get('access_token')
def get_collection_products(shop_url, access_token, collection_id):
query = """
query getProducts($id: ID!, $cursor: String) {
collection(id: $id) {
products(first: 250, after: $cursor) {
pageInfo {
hasNextPage
endCursor
}
edges {
node {
legacyResourceId
}
}
}
}
}
"""
url = f"https://{shop_url}/admin/api/2023-10/graphql.json"
headers = {
'X-Shopify-Access-Token': access_token,
'Content-Type': 'application/json',
}
product_ids = set()
cursor = None
has_next_page = True
while has_next_page:
variables = {"id": f"gid://shopify/Collection/{collection_id}"}
if cursor: variables["cursor"] = cursor
resp = requests.post(url, headers=headers, json={'query': query, 'variables': variables})
data = resp.json().get('data', {}).get('collection', {}).get('products', {})
if not data: break
for edge in data.get('edges', []):
product_ids.add(str(edge['node']['legacyResourceId']))
page_info = data.get('pageInfo', {})
has_next_page = page_info.get('hasNextPage', False)
cursor = page_info.get('endCursor')
return product_ids
def get_product_skus(shop_url, access_token, product_ids):
skus = set()
url = f"https://{shop_url}/admin/api/2023-10/products.json"
headers = {'X-Shopify-Access-Token': access_token}
for pid in product_ids:
r = requests.get(f"{url}?ids={pid}", headers=headers)
for p in r.json().get('products', []):
for v in p.get('variants', []):
skus.add(v.get('sku'))
return skus
shop_url, access_token = get_shopify_credentials()
padel_products = get_collection_products(shop_url, access_token, '487990952129')
padel_skus = get_product_skus(shop_url, access_token, padel_products)
filename = 'orders_export_1 (8)(1).csv'
sales_by_source = defaultdict(float)
with open(filename, 'r', encoding='utf-8-sig') as f:
reader = csv.DictReader(f)
for row in reader:
if row.get('Lineitem sku') in padel_skus:
if row.get('Financial Status') in ['paid', 'partially_refunded', 'refunded']:
source = row.get('Source', 'unknown')
price = float(row.get('Lineitem price') or 0)
qty = int(row.get('Lineitem quantity') or 0)
discount = float(row.get('Lineitem discount') or 0)
sales_by_source[source] += (price * qty) - discount
total = sum(sales_by_source.values())
print(f"Total from CSV: {total}")
for k,v in sales_by_source.items():
print(f" {k}: {v}")
-155
View File
@@ -1,155 +0,0 @@
import requests
import xmlrpc.client
from collections import defaultdict
ODOO_URL = 'http://localhost:16001'
ODOO_DB = 'hazard_new'
ODOO_USER = 'admin'
ODOO_PASS = 'admin'
def get_shopify_credentials():
common = xmlrpc.client.ServerProxy(f'{ODOO_URL}/xmlrpc/2/common')
uid = common.authenticate(ODOO_DB, ODOO_USER, ODOO_PASS, {})
models = xmlrpc.client.ServerProxy(f'{ODOO_URL}/xmlrpc/2/object')
config_ids = models.execute_kw(ODOO_DB, uid, ODOO_PASS, 'shopify.config', 'search', [[]])
config = models.execute_kw(ODOO_DB, uid, ODOO_PASS, 'shopify.config', 'read', [config_ids[0]], {'fields': ['shop_url', 'access_token']})[0]
return config.get('shop_url'), config.get('access_token')
def get_collection_products(shop_url, access_token, collection_id):
query = """
query getProducts($id: ID!, $cursor: String) {
collection(id: $id) {
products(first: 250, after: $cursor) {
pageInfo {
hasNextPage
endCursor
}
edges {
node {
legacyResourceId
}
}
}
}
}
"""
url = f"https://{shop_url}/admin/api/2023-10/graphql.json"
headers = {
'X-Shopify-Access-Token': access_token,
'Content-Type': 'application/json',
}
product_ids = set()
cursor = None
has_next_page = True
while has_next_page:
variables = {"id": f"gid://shopify/Collection/{collection_id}"}
if cursor:
variables["cursor"] = cursor
resp = requests.post(url, headers=headers, json={'query': query, 'variables': variables})
data = resp.json().get('data', {}).get('collection', {}).get('products', {})
if not data:
break
for edge in data.get('edges', []):
product_ids.add(str(edge['node']['legacyResourceId']))
page_info = data.get('pageInfo', {})
has_next_page = page_info.get('hasNextPage', False)
cursor = page_info.get('endCursor')
return product_ids
def get_may_orders(shop_url, access_token):
url = f"https://{shop_url}/admin/api/2023-10/orders.json"
headers = {
'X-Shopify-Access-Token': access_token,
'Content-Type': 'application/json',
}
params = {
'status': 'any',
'created_at_min': '2026-05-01T00:00:00-06:00',
'created_at_max': '2026-05-31T23:59:59-06:00',
'limit': 250
}
orders = []
while url:
resp = requests.get(url, headers=headers, params=params)
if resp.status_code != 200:
break
data = resp.json()
orders.extend(data.get('orders', []))
link_header = resp.headers.get('Link')
url = None
params = None
if link_header:
links = link_header.split(',')
for link in links:
if 'rel="next"' in link:
url = link[link.find('<')+1 : link.find('>')]
break
return orders
def main():
shop_url, access_token = get_shopify_credentials()
padel_products = get_collection_products(shop_url, access_token, '487990952129')
orders = get_may_orders(shop_url, access_token)
sales_by_channel = defaultdict(float)
items_by_channel = defaultdict(int)
total_sales = 0.0
total_items = 0
for order in orders:
if order.get('cancelled_at'):
continue
source = order.get('source_name', 'Unknown')
# calculate refunds for padel products
refunds_for_product = defaultdict(float)
refunded_qty_for_product = defaultdict(int)
for refund in order.get('refunds', []):
for refund_line_item in refund.get('refund_line_items', []):
line_item = refund_line_item.get('line_item', {})
product_id = str(line_item.get('product_id'))
if product_id in padel_products:
refunds_for_product[line_item.get('id')] += float(refund_line_item.get('subtotal', 0))
refunded_qty_for_product[line_item.get('id')] += int(refund_line_item.get('quantity', 0))
for item in order.get('line_items', []):
product_id = str(item.get('product_id'))
if product_id in padel_products:
price = float(item.get('price', 0))
quantity = int(item.get('quantity', 0))
discount = 0.0
for d in item.get('discount_allocations', []):
discount += float(d.get('amount', 0))
net_sale = (price * quantity) - discount
# subtract refund for this line item
line_item_id = item.get('id')
if line_item_id in refunds_for_product:
net_sale -= refunds_for_product[line_item_id]
quantity -= refunded_qty_for_product[line_item_id]
sales_by_channel[source] += net_sale
items_by_channel[source] += quantity
total_sales += net_sale
total_items += quantity
for channel in sales_by_channel:
print(f"{channel}: {sales_by_channel[channel]}")
if __name__ == '__main__':
main()
+1
View File
@@ -0,0 +1 @@
mcp
+88
View File
@@ -0,0 +1,88 @@
import os
import xmlrpc.client
from mcp.server.fastmcp import FastMCP
from typing import Dict, Any, List, Optional
# Odoo Credentials defaults to Hazard environment
URL = os.getenv("ODOO_URL", "http://localhost:16001")
DB = os.getenv("ODOO_DB", "hazard_new")
USER = os.getenv("ODOO_USER", "admin")
PASSWORD = os.getenv("ODOO_PASSWORD", "admin")
# FastMCP Initialization
mcp = FastMCP("odoo-mcp-hazard")
def _get_uid():
common = xmlrpc.client.ServerProxy(f'{URL}/xmlrpc/2/common')
return common.authenticate(DB, USER, PASSWORD, {})
def _get_models():
return xmlrpc.client.ServerProxy(f'{URL}/xmlrpc/2/object')
@mcp.tool()
def odoo_search_read(model: str, domain: List[Any], fields: Optional[List[str]] = None, limit: Optional[int] = None) -> List[Dict[str, Any]]:
"""
Search and read records from Odoo.
Args:
model: Odoo model name (e.g., 'sale.order', 'product.product')
domain: Search domain list of tuples (e.g., [['name', '=', '123']])
fields: Optional list of field names to return
limit: Optional maximum number of records to return
"""
uid = _get_uid()
if not uid:
raise Exception("Authentication failed with Odoo")
models = _get_models()
kwargs = {}
if fields:
kwargs['fields'] = fields
if limit:
kwargs['limit'] = limit
return models.execute_kw(DB, uid, PASSWORD, model, 'search_read', [domain], kwargs)
@mcp.tool()
def odoo_create(model: str, vals: Dict[str, Any]) -> int:
"""
Create a new record in Odoo.
Args:
model: Odoo model name (e.g., 'res.partner')
vals: Dictionary of field values for the new record
Returns:
The ID of the newly created record
"""
uid = _get_uid()
models = _get_models()
return models.execute_kw(DB, uid, PASSWORD, model, 'create', [vals])
@mcp.tool()
def odoo_write(model: str, ids: List[int], vals: Dict[str, Any]) -> bool:
"""
Update existing records in Odoo.
Args:
model: Odoo model name
ids: List of record IDs to update
vals: Dictionary of field values to update
"""
uid = _get_uid()
models = _get_models()
return models.execute_kw(DB, uid, PASSWORD, model, 'write', [ids, vals])
@mcp.tool()
def odoo_execute_method(model: str, method: str, ids: List[int], kwargs: Optional[Dict[str, Any]] = None) -> Any:
"""
Execute an arbitrary method on an Odoo model.
Args:
model: Odoo model name
method: Method name to execute (e.g., 'action_confirm')
ids: List of record IDs to apply the method to
kwargs: Optional keyword arguments for the method
"""
uid = _get_uid()
models = _get_models()
kwargs = kwargs or {}
return models.execute_kw(DB, uid, PASSWORD, model, method, [ids], kwargs)
if __name__ == "__main__":
mcp.run()
-37
View File
@@ -1,37 +0,0 @@
import csv
from collections import defaultdict
import xmlrpc.client
import requests
ODOO_URL = 'http://localhost:16001'
ODOO_DB = 'hazard_new'
ODOO_USER = 'admin'
ODOO_PASS = 'admin'
def get_shopify_credentials():
common = xmlrpc.client.ServerProxy(f'{ODOO_URL}/xmlrpc/2/common')
uid = common.authenticate(ODOO_DB, ODOO_USER, ODOO_PASS, {})
models = xmlrpc.client.ServerProxy(f'{ODOO_URL}/xmlrpc/2/object')
config_ids = models.execute_kw(ODOO_DB, uid, ODOO_PASS, 'shopify.config', 'search', [[]])
config = models.execute_kw(ODOO_DB, uid, ODOO_PASS, 'shopify.config', 'read', [config_ids[0]], {'fields': ['shop_url', 'access_token']})[0]
return config.get('shop_url'), config.get('access_token')
shop_url, access_token = get_shopify_credentials()
headers = {'X-Shopify-Access-Token': access_token, 'Content-Type': 'application/json'}
query = """
{
collection(id: "gid://shopify/Collection/487990952129") {
products(first: 250) {
edges {
node { legacyResourceId title }
}
}
}
}
"""
resp = requests.post(f"https://{shop_url}/admin/api/2023-10/graphql.json", headers=headers, json={'query': query})
product_titles = [edge['node']['title'] for edge in resp.json()['data']['collection']['products']['edges']]
print("PRODUCTOS EN LA COLECCIÓN PADEL 78:")
for t in product_titles:
print(t)
-34
View File
@@ -1,34 +0,0 @@
import xmlrpc.client
url = 'http://localhost:16001'
db = 'hazard_new'
username = 'admin'
password = '123'
# Try 123, then admin, then check
try:
common = xmlrpc.client.ServerProxy('{}/xmlrpc/2/common'.format(url))
uid = common.authenticate(db, username, 'admin', {})
if not uid:
uid = common.authenticate(db, 'admin', '123', {})
models = xmlrpc.client.ServerProxy('{}/xmlrpc/2/object'.format(url))
# Get sale order S00693
sale_orders = models.execute_kw(db, uid, 'admin', 'sale.order', 'search_read', [[['name', '=', 'S00693']]], {'fields': ['id', 'name', 'shopify_fulfillment_status', 'state', 'message_ids']})
print("Sale Order:", sale_orders)
# Get pickings for S00693
if sale_orders:
pickings = models.execute_kw(db, uid, 'admin', 'stock.picking', 'search_read', [[['origin', '=', 'S00693']]], {'fields': ['id', 'name', 'state', 'message_ids']})
print("Pickings:", pickings)
# get messages
msg_ids = pickings[0]['message_ids']
if msg_ids:
msgs = models.execute_kw(db, uid, 'admin', 'mail.message', 'read', [msg_ids], {'fields': ['body']})
for m in msgs:
print("Picking MSG:", m['body'])
except Exception as e:
print("Error:", e)
-20
View File
@@ -1,20 +0,0 @@
import xmlrpc.client
url = "http://localhost:16001"
db = "postgres"
username = "odoo"
password = "odoo"
common = xmlrpc.client.ServerProxy('{}/xmlrpc/2/common'.format(url))
uid = common.authenticate(db, username, password, {})
models = xmlrpc.client.ServerProxy('{}/xmlrpc/2/object'.format(url))
domain = [('name', '=', 'NAVY RED STRIPED POLO')]
templates = models.execute_kw(db, uid, password, 'product.template', 'search_read', [domain], {'fields': ['id', 'name', 'product_variant_ids']})
for t in templates:
print("Template:", t['id'], t['name'])
variants = models.execute_kw(db, uid, password, 'product.product', 'search_read', [[('id', 'in', t['product_variant_ids'])]], {'fields': ['id', 'default_code', 'shopify_variant_id']})
for v in variants:
print("Variant:", v)
-6
View File
@@ -1,6 +0,0 @@
import xmlrpc.client
url = "http://localhost:16001"
db = "hazard_new"
username = "admin"
password = "xxx" # Oh wait I don't know the password... let's just query from the container directly
-24
View File
@@ -1,24 +0,0 @@
import csv
import sys
input_file = '/var/www/vhosts/tesscorp.com.mx/hazard.tesscorp.com.mx/Etiquetas_EAN13_Hazard_2026-06-02.csv'
output_file = '/var/www/vhosts/tesscorp.com.mx/hazard.tesscorp.com.mx/update_barcodes.sql'
try:
with open(input_file, mode='r', encoding='utf-8') as f, open(output_file, mode='w', encoding='utf-8') as out:
reader = csv.DictReader(f)
out.write('BEGIN;\n')
count = 0
for row in reader:
sku = row.get('SKU', '').strip()
barcode = row.get('EAN-13', '').strip()
if sku and barcode:
sku_escaped = sku.replace("'", "''")
barcode_escaped = barcode.replace("'", "''")
out.write(f"UPDATE product_product SET barcode = '{barcode_escaped}' WHERE default_code = '{sku_escaped}';\n")
count += 1
out.write('COMMIT;\n')
print(f'Generated {count} update statements in {output_file}')
except Exception as e:
print(f"Error: {e}")
sys.exit(1)
-96
View File
@@ -1,96 +0,0 @@
# Resumen de Proyecto: Integración Odoo-Shopify (Hazard)
Este documento detalla el estado actual del módulo de integración **`hazard_shopify`**, resumiendo todas las características robustas, mejoras de seguridad, lógica de sincronización y herramientas avanzadas de exportación implementadas para la gestión eficiente del catálogo, inventario y códigos de barras EAN-13 entre Odoo y Shopify.
---
## 📊 Estado General de Funcionalidades
| Funcionalidad / Módulo | Estado | Descripción Técnica |
| :--- | :---: | :--- |
| **Autenticación OAuth y Conexión Segura** | ✅ **Completado** | Generación de token temporal de 24h, renovación automática con margen de 5 minutos, encriptación en base de datos. |
| **Prevención de Duplicados (Paso 0)** | ✅ **Completado** | Agrupación en memoria de variantes duplicadas en Shopify antes del proceso de importación/sync para prevenir errores de integridad. |
| **Generador de Barcodes EAN-13** | ✅ **Completado** | Generación secuencial automática basada en prefijo GS1 con cálculo de dígito verificador estándar y prevención de colisiones. |
| **Sincronizador Background de Barcodes** | ✅ **Completado** | Proceso en segundo plano por lotes (*batches* de 50) con control de rate limit y commits por registro para evitar pérdida de progreso. |
| **Importación de Catálogo (Shopify → Odoo)** | ✅ **Completado** | Creación inteligente de productos en Odoo (nombre, precios, imágenes base64 y mapeo de IDs de variantes e inventario). |
| **Control de Inventario Multi-Sucursal** | ✅ **Completado** | Mapeo interactivo de almacenes Odoo ↔ Sucursales Shopify. Actualizaciones individuales y masivas basadas en diferencias (*discrepancias*). |
| **Sincronización de Ventas (Pedidos)** | ✅ **Completado** | Importación automática mediante Odoo Cron cada 30 minutos o manual de pedidos recientes, asignando listas de precios y diarios. |
| **Reportes Avanzados y Herramientas** | ✅ **Completado** | Exportación de CSV para impresoras de etiquetas y Excel dinámico multi-hoja con inventario detallado y códigos reservados. |
---
## 🛠️ Detalles de Arquitectura y Funcionalidades
### 1. Autenticación y Conexión Segura
- **Mecanismo**: Implementa la autenticación mediante *OAuth Client Credentials*.
- **Renovación Inteligente**: En lugar de depender de tokens estáticos vulnerables o expiraciones manuales abruptas, el sistema almacena las credenciales del Dev Dashboard y verifica el token antes de cada llamada API (`_ensure_access_token`). Si el token está a 5 minutos o menos de expirar, se solicita y actualiza automáticamente.
- **Acciones Disponibles**:
- `⚡ Generar Token`: Genera y almacena el primer token.
- `Probar Conexión`: Envía una solicitud de ping rápida a la tienda para validar la integridad del canal.
### 2. Gestión de Duplicados en Web (Paso 0)
- **El Problema**: Los SKUs duplicados en Shopify corrompen el inventario y las ventas al intentar mapearse en Odoo.
- **La Solución**: Una pestaña inteligente dedicada ("Paso 0: Duplicados en Shopify") que agrupa en memoria todos los registros utilizando `NewId` de Odoo de forma optimizada.
- **Acciones**:
- Resalta en rojo los duplicados y advierte al usuario.
- Permite proponer un **SKU Sugerido (Corrección)** directamente en una grilla editable.
- Exporta un CSV de discrepancias para su revisión por parte del cliente antes de forzar la sincronización.
### 3. Generador e Inyector de Códigos de Barras EAN-13
- **Generación Idempotente**: Calcula dinámicamente códigos EAN-13 utilizando el prefijo de la empresa (por defecto `200001` para uso interno GS1).
- **Control de Secuencias**: Obtiene el máximo número de secuencia actual de la base de datos para continuar de forma consecutiva sin repetir códigos generados anteriormente.
- **Algoritmo EAN-13**: Cálculo matemático estricto del dígito verificador estándar GS1:
$$\text{CheckDigit} = (10 - (\text{Suma de dígitos impares} + 3 \times \text{Suma de dígitos pares}) \pmod{10}) \pmod{10}$$
- **Sincronización Background Multihilo**:
- Para evitar bloqueos HTTP (*Timeouts* de Odoo/Nginx), el botón `2. Subir a Shopify` ejecuta un hilo background (`threading.Thread`) que procesa variantes en lotes de 50.
- **Rate Limit**: Implementa una espera controlada de `0.6` segundos entre variantes, respetando los límites de la API de Shopify (2 peticiones/segundo).
- **Resiliencia Extrema**: Realiza un `cr.commit()` individual por cada variante subida con éxito. Si la red cae o el servidor se detiene, el progreso realizado queda guardado en la base de datos; al reactivar el proceso, simplemente continúa con las líneas en estado `pending` y `error`.
- **Manual Overrides**: Botón `✓ Marcar como Subidos` para corregir estados de forma masiva en Odoo si se sincronizaron por otra vía, y botón `Reintentar errores` para volver a meter en cola los fallos capturados.
### 4. Sincronización Multi-Sucursal de Inventario (Shopify → Odoo)
- **Mapeo Flexible**: Permite asociar N ubicaciones físicas de Shopify con N almacenes o ubicaciones de Odoo mediante la sub-tabla `shopify.location.mapping`.
- **Sincronización Inteligente**:
- Consulta en tiempo real (`requests.get`) los inventarios en las ubicaciones de Shopify asociadas.
- Permite auditar el catálogo detectando diferencias de stock Odoo vs Shopify.
- Ofrece el botón `Actualizar Stock Seleccionado (Shopify → Odoo)` para forzar a Odoo a igualar los niveles de stock de Shopify de forma masiva mediante *Inventory Adjustments* rápidos.
### 5. Reportes de Exportación Avanzados
- **CSV de Etiquetas**: Genera un archivo optimizado para software de impresión de etiquetas (Dymo, Zebra, etc.) con columnas estructuradas de variantes y códigos de barras.
- **Excel Multi-Hoja (openpyxl)**:
- **Hoja 1 ("Inventario")**: Tabla con diseño estilizado, cabeceras en azul premium, auto-ajuste de columnas y colores dinámicos según el estado de Shopify (verde para subido, amarillo para pendiente, rojo para error).
- **Hoja 2 ("Próximos Disponibles")**: Pre-calcula los siguientes **500 códigos EAN-13** consecutivos basados en el contador secuencial activo. Tiene diseño en color verde GS1 y tipografía de tipo Courier New para fácil lectura por el personal de almacén, previniendo que asignen códigos manualmente de manera incorrecta.
---
## 📂 Archivos del Módulo en el Repositorio
El código fuente principal y las vistas XML están estructurados de la siguiente manera:
1. **Modelos de Base de Datos**:
- [`shopify_config.py`](file:///var/www/vhosts/tesscorp.com.mx/hazard.tesscorp.com.mx/Addons/custom/hazard_shopify/models/shopify_config.py): Lógica principal del token, generación secuencial de EAN-13, hilo background resiliente, importación de stock multi-sucursal y formateo de hojas Excel de alta estética.
- [`sku_simulation_line.py`](file:///var/www/vhosts/tesscorp.com.mx/hazard.tesscorp.com.mx/Addons/custom/hazard_shopify/models/sku_simulation_line.py): Estructura de datos por variante, campos computados para la detección de duplicados en memoria, y lógica de creación de variantes e imágenes base64 en Odoo.
2. **Interfaz de Usuario (Vistas XML)**:
- [`shopify_config_views.xml`](file:///var/www/vhosts/tesscorp.com.mx/hazard.tesscorp.com.mx/Addons/custom/hazard_shopify/views/shopify_config_views.xml): Vistas de formulario Odoo enriquecidas estéticamente con paneles de estadísticas en vivo (Dashboards con badges CSS para estados), menús personalizados e instrucciones interactivas.
---
## 🚀 Plan de Despliegue y Pruebas
Para garantizar una transición segura a producción, se recomienda seguir este flujo de verificación:
### Fase 1: Pruebas de Generación Local
1. Ingresar a Odoo y abrir el registro de **Configuración de Shopify**.
2. Modificar el campo **Prefijo empresa (6 dígitos)** si es necesario (ej: `200002`).
3. Presionar el botón `1. Generar EAN-13` en la pestaña **🔖 Barcodes EAN-13**.
4. Validar en la tabla que los códigos se asignen secuencialmente de forma correcta y que el último dígito corresponda al algoritmo verificador EAN-13.
5. Exportar el **Excel (Inventario + Próximos)** y verificar que la hoja 2 muestre 500 códigos subsiguientes listos.
### Fase 2: Sincronización en Staging (Lote Pequeño)
1. Filtrar variantes utilizando el campo de búsqueda o categorías.
2. Sincronizar un lote controlado de 5 a 10 productos a Shopify.
3. Presionar `2. Subir a Shopify` y monitorear el log en vivo o recargar el formulario para ver cómo el progreso incrementa y el contador de "Pendientes" baja.
4. Validar directamente en el panel de Shopify que la variante haya recibido su código de barras correspondiente.
### Fase 3: Ejecución Masiva
1. Una vez validada la Fase 2, ejecutar la sincronización para las más de 1,100 variantes pendientes.
2. El hilo background continuará procesando a un ritmo seguro de ~2 variantes por segundo sin congelar la pantalla de Odoo, completando todo el catálogo de forma autónoma.
-23
View File
@@ -1,23 +0,0 @@
import xmlrpc.client
url = 'http://localhost:16001'
db = 'hazard_new'
username = 'admin'
password = '123'
try:
common = xmlrpc.client.ServerProxy('{}/xmlrpc/2/common'.format(url))
uid = common.authenticate(db, username, 'admin', {})
models = xmlrpc.client.ServerProxy('{}/xmlrpc/2/object'.format(url))
# 4. Verificar Ubicación de Subcontratación
locations = models.execute_kw(db, uid, 'admin', 'stock.location', 'search_read',
[['|', ['name', 'ilike', 'Subcontrataci'], ['name', 'ilike', 'Subcontract']]],
{'fields': ['name', 'usage', 'complete_name']})
print("\nUbicaciones de Subcontratación encontradas:")
for loc in locations:
print(f" - {loc['complete_name']} (Usage: {loc['usage']})")
except Exception as e:
print("Error:", e)
-125
View File
@@ -1,125 +0,0 @@
import xmlrpc.client
url = 'http://localhost:16001'
db = 'hazard_new'
username = 'admin'
password = '123'
try:
common = xmlrpc.client.ServerProxy('{}/xmlrpc/2/common'.format(url))
uid = common.authenticate(db, username, 'admin', {})
models = xmlrpc.client.ServerProxy('{}/xmlrpc/2/object'.format(url))
print("Iniciando creación de datos de prueba...")
# 1. Buscar IDs de Categorías
cat_insumo = models.execute_kw(db, uid, 'admin', 'product.category', 'search', [[['name', '=', 'Insumos / Materia Prima']]])
cat_custom = models.execute_kw(db, uid, 'admin', 'product.category', 'search', [[['name', '=', 'Producto Final Custom']]])
if not cat_insumo or not cat_custom:
print("Error: No se encontraron las categorías. ¿Se ejecutó el paso 2?")
exit(1)
cat_insumo_id = cat_insumo[0]
cat_custom_id = cat_custom[0]
# 2. Crear Partners
vendor_id = models.execute_kw(db, uid, 'admin', 'res.partner', 'create', [{
'name': 'Fábrica China Test',
'is_company': True,
'supplier_rank': 1,
}])
print("Vendor creado:", vendor_id)
client_id = models.execute_kw(db, uid, 'admin', 'res.partner', 'create', [{
'name': 'Club de Golf Test',
'is_company': True,
'customer_rank': 1,
}])
print("Cliente creado:", client_id)
# 3. Crear Productos
insumo_id = models.execute_kw(db, uid, 'admin', 'product.product', 'create', [{
'name': 'Tela Azul Premium (Test)',
'type': 'product',
'categ_id': cat_insumo_id,
'uom_id': 1, # Unidades
'uom_po_id': 1,
'standard_price': 50.0,
}])
print("Insumo creado:", insumo_id)
# Para el producto final, creamos primero la línea de proveedor (seller)
seller_id = models.execute_kw(db, uid, 'admin', 'product.supplierinfo', 'create', [{
'partner_id': vendor_id,
'price': 250.0, # Costo de maquila
'delay': 7,
}])
product_id = models.execute_kw(db, uid, 'admin', 'product.product', 'create', [{
'name': 'Camisa Custom Club de Golf (Test)',
'type': 'product',
'categ_id': cat_custom_id,
'list_price': 800.0, # Precio de venta
'seller_ids': [(4, seller_id)],
}])
print("Producto Final creado:", product_id)
# Obtener el product_tmpl_id para la BoM
prod_data = models.execute_kw(db, uid, 'admin', 'product.product', 'read', [[product_id]], {'fields': ['product_tmpl_id']})
tmpl_id = prod_data[0]['product_tmpl_id'][0]
# 4. Crear BoM de Subcontratación
bom_id = models.execute_kw(db, uid, 'admin', 'mrp.bom', 'create', [{
'product_tmpl_id': tmpl_id,
'type': 'subcontract',
'subcontractor_ids': [(4, vendor_id)],
'bom_line_ids': [
(0, 0, {
'product_id': insumo_id,
'product_qty': 1.5,
})
]
}])
print("Lista de Materiales (BoM) creada:", bom_id)
# 5. Agregar Inventario Inicial (100 unidades de Tela)
wh_stock = models.execute_kw(db, uid, 'admin', 'stock.location', 'search', [[['usage', '=', 'internal'], ['name', '=', 'Stock']]])
if wh_stock:
quant_id = models.execute_kw(db, uid, 'admin', 'stock.quant', 'create', [{
'product_id': insumo_id,
'location_id': wh_stock[0],
'inventory_quantity': 100.0,
}])
models.execute_kw(db, uid, 'admin', 'stock.quant', 'action_apply_inventory', [[quant_id]])
print("Inventario de Tela agregado (100 uds).")
# 6. Crear Cotización (SO)
so_id = models.execute_kw(db, uid, 'admin', 'sale.order', 'create', [{
'partner_id': client_id,
'order_line': [
(0, 0, {
'product_id': product_id,
'product_uom_qty': 5.0,
})
]
}])
print("Cotización de Venta Creada:", so_id)
# 7. Crear Orden de Compra (PO)
po_id = models.execute_kw(db, uid, 'admin', 'purchase.order', 'create', [{
'partner_id': vendor_id,
'order_line': [
(0, 0, {
'product_id': product_id,
'product_qty': 5.0,
'price_unit': 250.0,
})
]
}])
print("Orden de Compra Creada:", po_id)
print("--- ¡Datos de prueba creados exitosamente! ---")
except Exception as e:
print("Error general:", e)
-46
View File
@@ -1,46 +0,0 @@
import xmlrpc.client
url = 'http://localhost:16001'
db = 'hazard_new'
username = 'admin'
password = '123'
try:
common = xmlrpc.client.ServerProxy('{}/xmlrpc/2/common'.format(url), allow_none=True)
uid = common.authenticate(db, username, 'admin', {})
models = xmlrpc.client.ServerProxy('{}/xmlrpc/2/object'.format(url), allow_none=True)
# IDs from previous run
vendor_id = 277
client_id = 278
product_id = 10017
# 6. Crear Cotización (SO)
so_id = models.execute_kw(db, uid, 'admin', 'sale.order', 'create', [{
'partner_id': client_id,
'order_line': [
(0, 0, {
'product_id': product_id,
'product_uom_qty': 5.0,
})
]
}])
print("Cotización de Venta Creada:", so_id)
# 7. Crear Orden de Compra (PO)
po_id = models.execute_kw(db, uid, 'admin', 'purchase.order', 'create', [{
'partner_id': vendor_id,
'order_line': [
(0, 0, {
'product_id': product_id,
'product_qty': 5.0,
'price_unit': 250.0,
})
]
}])
print("Orden de Compra Creada:", po_id)
print("--- ¡Datos complementarios creados exitosamente! ---")
except Exception as e:
print("Error general:", e)
+249
View File
@@ -0,0 +1,249 @@
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js";
import pg from 'pg';
const server = new Server(
{
name: "shopify-mcp-server",
version: "1.0.0",
},
{
capabilities: {
tools: {},
},
}
);
// Helper to get Shopify credentials from Odoo Database
async function getShopifyCredentials() {
const client = new pg.Client({
host: '172.18.0.2',
port: 5432,
user: 'odoo',
password: 'odoo',
database: 'hazard_new'
});
try {
await client.connect();
const res = await client.query('SELECT access_token, shop_url FROM shopify_config LIMIT 1;');
if (res.rows.length === 0) {
throw new Error("No Shopify configuration found in database.");
}
return {
accessToken: res.rows[0].access_token,
shopUrl: res.rows[0].shop_url
};
} finally {
await client.end();
}
}
// Define available tools
server.setRequestHandler(ListToolsRequestSchema, async () => {
return {
tools: [
{
name: "shopify_get_order",
description: "Retrieve a complete Shopify order JSON by its order name (e.g., 4854)",
inputSchema: {
type: "object",
properties: {
order_name: {
type: "string",
description: "The order number/name without the # (e.g. 4854)",
},
},
required: ["order_name"],
},
},
{
name: "shopify_get_product_by_sku",
description: "Retrieve product variants from Shopify matching a specific SKU",
inputSchema: {
type: "object",
properties: {
sku: {
type: "string",
description: "The SKU of the product variant",
},
},
required: ["sku"],
},
},
{
name: "shopify_get_inventory_by_sku",
description: "Retrieve inventory levels across all locations for a specific SKU",
inputSchema: {
type: "object",
properties: {
sku: {
type: "string",
description: "The SKU of the product variant",
},
},
required: ["sku"],
},
},
{
name: "shopify_get_order_transactions",
description: "Retrieve the financial transactions (payments/refunds) for an order by its name (e.g., 4854)",
inputSchema: {
type: "object",
properties: {
order_name: {
type: "string",
description: "The order number/name without the # (e.g. 4854)",
},
},
required: ["order_name"],
},
}
],
};
});
// Handle tool execution
server.setRequestHandler(CallToolRequestSchema, async (request) => {
try {
const creds = await getShopifyCredentials();
const headers = {
"X-Shopify-Access-Token": creds.accessToken,
"Content-Type": "application/json"
};
const baseUrl = `https://${creds.shopUrl}/admin/api/2023-04`;
switch (request.params.name) {
case "shopify_get_order": {
const orderName = String(request.params.arguments.order_name).replace('#', '');
const response = await fetch(`${baseUrl}/orders.json?name=${orderName}&status=any`, { headers });
const data = await response.json();
if (!data.orders || data.orders.length === 0) {
return {
content: [{ type: "text", text: `Order ${orderName} not found.` }],
isError: true,
};
}
return {
content: [{ type: "text", text: JSON.stringify(data.orders[0], null, 2) }],
};
}
case "shopify_get_product_by_sku": {
const sku = String(request.params.arguments.sku);
const query = `
{
productVariants(first: 5, query: "sku:${sku}") {
edges {
node {
id
title
sku
inventoryQuantity
product {
title
}
}
}
}
}
`;
const response = await fetch(`${baseUrl}/graphql.json`, {
method: 'POST',
headers,
body: JSON.stringify({ query })
});
const data = await response.json();
return {
content: [{ type: "text", text: JSON.stringify(data, null, 2) }],
};
}
case "shopify_get_inventory_by_sku": {
const sku = String(request.params.arguments.sku);
const query = `
{
productVariants(first: 5, query: "sku:${sku}") {
edges {
node {
sku
inventoryItem {
inventoryLevels(first: 10) {
edges {
node {
quantities(names: ["available"]) {
name
quantity
}
location {
id
name
}
}
}
}
}
}
}
}
}
`;
const response = await fetch(`${baseUrl}/graphql.json`, {
method: 'POST',
headers,
body: JSON.stringify({ query })
});
const data = await response.json();
return {
content: [{ type: "text", text: JSON.stringify(data, null, 2) }],
};
}
case "shopify_get_order_transactions": {
const orderName = String(request.params.arguments.order_name).replace('#', '');
// First get the order ID
const orderResp = await fetch(`${baseUrl}/orders.json?name=${orderName}&status=any&fields=id,name`, { headers });
const orderData = await orderResp.json();
if (!orderData.orders || orderData.orders.length === 0) {
return {
content: [{ type: "text", text: `Order ${orderName} not found.` }],
isError: true,
};
}
const orderId = orderData.orders[0].id;
const transResp = await fetch(`${baseUrl}/orders/${orderId}/transactions.json`, { headers });
const transData = await transResp.json();
return {
content: [{ type: "text", text: JSON.stringify(transData, null, 2) }],
};
}
default:
throw new Error(`Unknown tool: ${request.params.name}`);
}
} catch (error) {
return {
content: [{ type: "text", text: `Error: ${error.message}` }],
isError: true,
};
}
});
// Run the server using stdio transport
async function run() {
const transport = new StdioServerTransport();
await server.connect(transport);
console.error("Shopify MCP Server running on stdio");
}
run().catch((error) => {
console.error("Server error:", error);
process.exit(1);
});
+1321
View File
File diff suppressed because it is too large Load Diff
+16
View File
@@ -0,0 +1,16 @@
{
"name": "shopify-mcp",
"version": "1.0.0",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": [],
"author": "",
"license": "ISC",
"description": "",
"dependencies": {
"@modelcontextprotocol/sdk": "^1.29.0",
"pg": "^8.22.0"
}
}
-50
View File
@@ -1,50 +0,0 @@
import requests
config_script = """
config = env['shopify.config'].search([], limit=1)
token = config._ensure_access_token()
shop_domain = config.shop_url.strip().lower().replace('https://', '').split('/')[0]
product = env['product.template'].search([('name', '=', 'NAVY RED STRIPED POLO')], limit=1)
print(token, shop_domain, product.shopify_id)
"""
import subprocess
out = subprocess.check_output(['docker', 'exec', '-i', 'odoo-16-hazard-new', 'odoo', 'shell', '-d', 'hazard_new', '--no-http', '-c', '/etc/odoo/odoo.conf'], input=config_script.encode()).decode()
lines = [l for l in out.split('\n') if l and not l.startswith('2026') and not l.startswith('Odoo')]
token, shop_domain, shopify_id = lines[-1].split()
url = f"https://{shop_domain}/admin/api/2024-01/graphql.json"
headers = {
"X-Shopify-Access-Token": token,
"Content-Type": "application/json"
}
query = """
mutation productVariantsBulkCreate($productId: ID!, $variants: [ProductVariantsBulkInput!]!) {
productVariantsBulkCreate(productId: $productId, variants: $variants) {
productVariants {
id
title
sku
price
}
userErrors {
field
message
}
}
}
"""
variables = {
"productId": f"gid://shopify/Product/{shopify_id}",
"variants": [
{
"price": "100.00",
"inventoryItem": {"sku": "HRS-POL-REDSTRP-M-NVBL-XXL", "tracked": True},
"optionValues": [
{"name": "XXL", "optionName": "Talla"}
]
}
]
}
resp = requests.post(url, headers=headers, json={'query': query, 'variables': variables})
print("Status:", resp.status_code)
print("Response:", resp.json())
-50
View File
@@ -1,50 +0,0 @@
import requests
config_script = """
config = env['shopify.config'].search([], limit=1)
token = config._ensure_access_token()
shop_domain = config.shop_url.strip().lower().replace('https://', '').split('/')[0]
product = env['product.template'].search([('name', '=', 'NAVY RED STRIPED POLO')], limit=1)
print(token, shop_domain, product.shopify_id)
"""
import subprocess
out = subprocess.check_output(['docker', 'exec', '-i', 'odoo-16-hazard-new', 'odoo', 'shell', '-d', 'hazard_new', '--no-http', '-c', '/etc/odoo/odoo.conf'], input=config_script.encode()).decode()
lines = [l for l in out.split('\n') if l and not l.startswith('2026') and not l.startswith('Odoo')]
token, shop_domain, shopify_id = lines[-1].split()
url = f"https://{shop_domain}/admin/api/2024-01/graphql.json"
headers = {
"X-Shopify-Access-Token": token,
"Content-Type": "application/json"
}
query = """
mutation productVariantsBulkCreate($productId: ID!, $variants: [ProductVariantsBulkInput!]!) {
productVariantsBulkCreate(productId: $productId, variants: $variants) {
productVariants {
id
title
sku
price
}
userErrors {
field
message
}
}
}
"""
variables = {
"productId": f"gid://shopify/Product/{shopify_id}",
"variants": [
{
"price": "100.00",
"inventoryItem": {"sku": "HRS-POL-REDSTRP-M-NVBL-XXL", "tracked": True},
"optionValues": [
{"linkedMetafieldValue": "XXL", "optionName": "Talla"}
]
}
]
}
resp = requests.post(url, headers=headers, json={'query': query, 'variables': variables})
print("Status:", resp.status_code)
print("Response:", resp.json())
-50
View File
@@ -1,50 +0,0 @@
import requests
config_script = """
config = env['shopify.config'].search([], limit=1)
token = config._ensure_access_token()
shop_domain = config.shop_url.strip().lower().replace('https://', '').split('/')[0]
product = env['product.template'].search([('name', '=', 'NAVY RED STRIPED POLO')], limit=1)
print(token, shop_domain, product.shopify_id)
"""
import subprocess
out = subprocess.check_output(['docker', 'exec', '-i', 'odoo-16-hazard-new', 'odoo', 'shell', '-d', 'hazard_new', '--no-http', '-c', '/etc/odoo/odoo.conf'], input=config_script.encode()).decode()
lines = [l for l in out.split('\n') if l and not l.startswith('2026') and not l.startswith('Odoo')]
token, shop_domain, shopify_id = lines[-1].split()
url = f"https://{shop_domain}/admin/api/2024-01/graphql.json"
headers = {
"X-Shopify-Access-Token": token,
"Content-Type": "application/json"
}
query = """
mutation productVariantsBulkCreate($productId: ID!, $variants: [ProductVariantsBulkInput!]!) {
productVariantsBulkCreate(productId: $productId, variants: $variants) {
productVariants {
id
title
sku
price
}
userErrors {
field
message
}
}
}
"""
variables = {
"productId": f"gid://shopify/Product/{shopify_id}",
"variants": [
{
"price": "100.00",
"inventoryItem": {"sku": "HRS-POL-REDSTRP-M-NVBL-XXL", "tracked": True},
"optionValues": [
{"name": "XXL", "linkedMetafieldValue": "XXL", "optionName": "Talla"}
]
}
]
}
resp = requests.post(url, headers=headers, json={'query': query, 'variables': variables})
print("Status:", resp.status_code)
print("Response:", resp.json())
-4
View File
@@ -1,4 +0,0 @@
import requests
api_base = "https://hazard-clothing.myshopify.com/admin/api/2024-01" # Or whatever shop_domain is
# Wait, I need the token and shop domain. Let's just query from Odoo.
-46
View File
@@ -1,46 +0,0 @@
import requests
import xmlrpc.client
ODOO_URL = 'http://localhost:16001'
ODOO_DB = 'hazard_new'
ODOO_USER = 'admin'
ODOO_PASS = 'admin'
def get_shopify_credentials():
common = xmlrpc.client.ServerProxy(f'{ODOO_URL}/xmlrpc/2/common')
uid = common.authenticate(ODOO_DB, ODOO_USER, ODOO_PASS, {})
models = xmlrpc.client.ServerProxy(f'{ODOO_URL}/xmlrpc/2/object')
config_ids = models.execute_kw(ODOO_DB, uid, ODOO_PASS, 'shopify.config', 'search', [[]])
if not config_ids:
return None, None
config = models.execute_kw(ODOO_DB, uid, ODOO_PASS, 'shopify.config', 'read', [config_ids[0]], {'fields': ['shop_url', 'access_token']})[0]
return config.get('shop_url'), config.get('access_token')
def get_collections(shop_url, access_token):
# Using GraphQL
query = """
{
collections(first: 200, query: "title:*78*") {
edges {
node {
id
title
}
}
}
}
"""
url = f"https://{shop_url}/admin/api/2023-10/graphql.json"
headers = {
'X-Shopify-Access-Token': access_token,
'Content-Type': 'application/json',
}
resp = requests.post(url, headers=headers, json={'query': query})
for edge in resp.json().get('data', {}).get('collections', {}).get('edges', []):
print(f"ID: {edge['node']['id']}, Title: {edge['node']['title']}")
if __name__ == '__main__':
shop_url, access_token = get_shopify_credentials()
if shop_url:
get_collections(shop_url, access_token)
-16
View File
@@ -1,16 +0,0 @@
import csv
from collections import defaultdict
filename = 'orders_export_1 (8)(1).csv'
total = 0
with open(filename, 'r', encoding='utf-8-sig') as f:
reader = csv.DictReader(f)
for row in reader:
name = row.get('Lineitem name', '').upper()
if 'CLUB 78' in name:
price = float(row.get('Lineitem price') or 0)
qty = int(row.get('Lineitem quantity') or 0)
discount = float(row.get('Lineitem discount') or 0)
total += (price * qty) - discount
print(f"Total 'CLUB 78': {total}")