- Custom modules: hazard_shopify, hazard_inventory_report, hazard_website - Third-party modules: bi_sql_editor, query_deluxe, sql_request_abstract, l10n_mx_sat_sync_itadmin_ee - Docker setup: Odoo 16 + PostgreSQL 17 - Utility scripts: backup_hazard.sh, update_module.py - Agent configuration: .agents/AGENTS.md - Security: Enterprise modules, credentials, backups and production data excluded via .gitignore
341 lines
13 KiB
Python
341 lines
13 KiB
Python
#!/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()
|