chore: clean up historical scripts, add MCP configurations, update agent rules
This commit is contained in:
+268
-1
@@ -52,7 +52,7 @@
|
||||
- **NUNCA** usar `print()`. Siempre usar `logging` con `_logger = logging.getLogger(__name__)`.
|
||||
- Usar prefijos descriptivos en logs: `_logger.info("[Módulo] Mensaje")`.
|
||||
- Siempre agregar docstrings a funciones y clases.
|
||||
- Preferir código legible sobre código compacto.
|
||||
|
||||
|
||||
### Despliegue de Cambios
|
||||
1. **Cambios en archivos `.py` (modelos/lógica):** Requiere restart del contenedor.
|
||||
@@ -88,3 +88,270 @@
|
||||
|
||||
## 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.
|
||||
|
||||
## 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.
|
||||
|
||||
|
||||
@@ -39,6 +39,7 @@ build/
|
||||
venv/
|
||||
.venv/
|
||||
env/
|
||||
mcp_env/
|
||||
|
||||
# --- Logs ---
|
||||
logs/
|
||||
|
||||
@@ -92,5 +92,37 @@
|
||||
<field name="priority">8</field>
|
||||
</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>
|
||||
</odoo>
|
||||
|
||||
@@ -9,3 +9,5 @@ from . import shopify_config
|
||||
from . import res_users
|
||||
from . import shopify_cost_line
|
||||
from . import product_label_layout
|
||||
from . import shopify_stock_queue
|
||||
from . import stock_quant
|
||||
|
||||
@@ -644,48 +644,9 @@ class StockPicking(models.Model):
|
||||
except Exception:
|
||||
pass # No romper el flujo si el chatter falla
|
||||
|
||||
# 2. ── AUTO-STOCK SYNC (Traslados y Movimientos de Stock) ──
|
||||
products = picking.move_ids.mapped('product_id').filtered(lambda p: p.shopify_inventory_item_id)
|
||||
if products:
|
||||
config = self.env['shopify.config'].sudo().search([('state', '=', 'confirmed')], limit=1)
|
||||
if config and config.auto_sync_products:
|
||||
_logger.info(
|
||||
"[Shopify Stock Auto-Sync] Albarán '%s' validado. "
|
||||
"Sincronizando stock de %s variantes vinculadas a Shopify.",
|
||||
picking.name, len(products)
|
||||
)
|
||||
try:
|
||||
import threading
|
||||
import odoo
|
||||
# 2. ── AUTO-STOCK SYNC ──
|
||||
# ELIMINADO: Ahora se maneja de forma global mediante shopify.stock.queue
|
||||
# al sobrescribir stock.quant._update_available_quantity, lo cual
|
||||
# cubre ajustes de inventario, ventas y traslados.
|
||||
|
||||
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
|
||||
|
||||
@@ -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
|
||||
@@ -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.*
|
||||
@@ -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).
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
@@ -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
@@ -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.
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
@@ -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']}")
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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.")
|
||||
@@ -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,6 +2,7 @@ services:
|
||||
db:
|
||||
image: postgres:17
|
||||
container_name: odoo-16-db-hazard-new
|
||||
restart: always
|
||||
environment:
|
||||
POSTGRES_USER: odoo
|
||||
POSTGRES_PASSWORD: odoo
|
||||
@@ -12,6 +13,7 @@ services:
|
||||
odoo:
|
||||
build: .
|
||||
container_name: odoo-16-hazard-new
|
||||
restart: always
|
||||
depends_on:
|
||||
- db
|
||||
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!
|
||||
@@ -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.
|
||||
@@ -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
@@ -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}")
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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}")
|
||||
@@ -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()
|
||||
@@ -0,0 +1 @@
|
||||
mcp
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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.
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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);
|
||||
});
|
||||
Generated
+1321
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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.
|
||||
@@ -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
@@ -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}")
|
||||
Reference in New Issue
Block a user