Files
hazard_odoo/fix_svl_missing.py
T
tess_admin 28d9e56f47 🎉 Initial commit - Hazard Odoo 16 project
- Custom modules: hazard_shopify, hazard_inventory_report, hazard_website
- Third-party modules: bi_sql_editor, query_deluxe, sql_request_abstract, l10n_mx_sat_sync_itadmin_ee
- Docker setup: Odoo 16 + PostgreSQL 17
- Utility scripts: backup_hazard.sh, update_module.py
- Agent configuration: .agents/AGENTS.md
- Security: Enterprise modules, credentials, backups and production data excluded via .gitignore
2026-06-30 06:12:35 -06:00

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()